Compare commits

...
19 Commits
Author SHA1 Message Date
robin 8ee2af480d Merge pull request 'Fixed position missing datatype' (#50) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 2m2s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-11 19:53:45 +02:00
robin c0ff985061 fix: position missing datatype
PR Checks / lint-test-and-build (pull_request) Successful in 40s
2026-09-11 19:51:21 +02:00
robin 616adbbf75 Merge pull request 'Re-order lists' (#49) from dev into main
Build and Deploy / build-and-deploy (push) Failing after 2m5s
Build and Deploy / sync-dev (push) Skipped
2026-09-11 19:47:23 +02:00
robin 2c97ca7641 fix: list fixes and ci improvement
PR Checks / lint-test-and-build (pull_request) Successful in 3m6s
2026-09-11 19:43:32 +02:00
robin fd50ce99a7 fix: updated list_test.go
PR Checks / lint-test-and-build (pull_request) Successful in 3m5s
2026-09-11 19:27:22 +02:00
robin 7def43fbeb feat: re-order lists
PR Checks / lint-test-and-build (pull_request) Failing after 2m40s
2026-09-11 19:13:03 +02:00
robin 7821009f96 Merge pull request 'Merging PRs now requires linting/formatting/tests and builds to complete' (#48) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 2m3s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-09 17:19:40 +02:00
robin c4ce8e66b6 fix: removed unused packages from 'Install system dependencies' step
PR Checks / lint-test-and-build (pull_request) Successful in 3m7s
2026-09-09 17:13:17 +02:00
robin a9328043b7 feat: moved parts of CI/CD to pull requests, rather than after merging
PR Checks / lint-test-and-build (pull_request) Successful in 3m7s
2026-09-09 17:06:19 +02:00
robin 3b3afedd64 Merge pull request 'Updated dependencies' (#47) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 5m3s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-09 16:55:19 +02:00
robin 469222054d chore: updated dependencies 2026-09-09 16:54:44 +02:00
robin 0bb875020b Merge pull request 'Fixed WithinTx and improved test coverage' (#46) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 4m56s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-09 16:42:27 +02:00
robin 726c3554eb feat: added repo_test.go 2026-09-09 16:41:30 +02:00
robin c8a5da61ef fix: re-worked list tests go 2026-09-09 16:34:26 +02:00
robin 77a4ccc520 fix: added missing spaces for tx savepoints 2026-09-09 16:33:47 +02:00
robin 4a967ef002 fix: WithinTx now actually places the transaction into the context 2026-09-09 16:30:43 +02:00
robin 366386c8bf test: re-worked user repo tests 2026-09-09 16:25:48 +02:00
robin f81eb3be96 Merge pull request 'Changed image for sync-dev job, to include node binary for checkout@v4' (#45) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 4m58s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-06 19:33:02 +02:00
robin 560695c067 fix: changed image for sync-dev job, to include node binary for checkout@v4 2026-09-06 19:32:19 +02:00
16 changed files with 1523 additions and 253 deletions
+3 -23
View File
@@ -15,35 +15,15 @@ jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: golang:1.27-bookworm image: node:24-bookworm
steps: steps:
- name: Install system dependencies - name: Install system dependencies
run: | run: |
apt-get update && apt-get install -y --no-install-recommends git openssh-client curl apt-get update && apt-get install -y --no-install-recommends openssh-client
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Lint
run: make lint
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Not gofmt'd:"
echo "$unformatted"
exit 1
fi
- name: Test
run: make test
- name: Build
run: make build
- name: Configure SSH - name: Configure SSH
env: env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
@@ -68,7 +48,7 @@ jobs:
needs: build-and-deploy needs: build-and-deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: golang:1.27-bookworm image: node:24-bookworm
permissions: permissions:
contents: write contents: write
steps: steps:
+45
View File
@@ -0,0 +1,45 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
lint-test-and-build:
runs-on: ubuntu-latest
container:
image: golang:1.27-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
steps:
- name: Install system dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends curl
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Not gofmt'd:"
echo "$unformatted"
exit 1
fi
- name: Download modules
run: go mod download
- name: Lint
run: make lint
- name: Test
run: make test
- name: Build
run: make build
+9 -9
View File
@@ -5,8 +5,8 @@ go 1.27
require ( require (
github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.20.1
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.11.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
@@ -16,7 +16,7 @@ require (
go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/sdk/metric v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0
go.opentelemetry.io/otel/trace v1.46.0 go.opentelemetry.io/otel/trace v1.46.0
golang.org/x/crypto v0.56.0 golang.org/x/crypto v0.57.0
) )
require ( require (
@@ -37,12 +37,12 @@ require (
go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
google.golang.org/grpc v1.83.2 // indirect google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
) )
+23
View File
@@ -1,5 +1,6 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -19,8 +20,10 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -36,6 +39,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -50,6 +55,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -61,12 +68,14 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -109,14 +118,24 @@ golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU= google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU=
@@ -125,12 +144,16 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 h1:NCe/UiklGd/9xjT+ROBVhJ1kf6TRQaFedsR+z7u1gvo= google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 h1:NCe/UiklGd/9xjT+ROBVhJ1kf6TRQaFedsR+z7u1gvo=
google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4/go.mod h1:fJ2lYaWjqNknJyQBOCd0fA3HnEElJqGplH71a2txi+g= google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4/go.mod h1:fJ2lYaWjqNknJyQBOCd0fA3HnEElJqGplH71a2txi+g=
google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 h1:O4Tjo2vlGeGM3+tNgwJrGj5fQHO2V71gDkFqbm328C8=
google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:r4KD2hOq82JBWpTWkJ9NZLf6EwmRvAPIFmk7hPNtd+0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+47
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -232,6 +233,52 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request)
response.Status(w, http.StatusNoContent) response.Status(w, http.StatusNoContent)
} }
// OrderLists handles re-ordering a users lists
//
// @Summary Order lists of a user
// @Description Re-assigns the display order of all users lists
// @Tags List
// @Accept json
// @Produce json
// @Param payload body request.OrderListsPayload true "Order lists payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to order lists"
// @Router /lists/order [post]
func (h *ListHandler) OrderLists(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.OrderListsPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode order lists payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
return
}
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
return
}
err = h.ListService.OrderLists(ctx, authContext.UserID, payload.ListIDs)
if err != nil {
if errors.Is(err, domain.ErrStaleListIDs) {
response.Error(ctx, w, http.StatusBadRequest, "stale list IDs")
} else {
response.Error(ctx, w, http.StatusInternalServerError, "failed to order list items")
}
slog.ErrorContext(ctx, "failed to order lists", slog.Any("error", err))
return
}
slog.InfoContext(ctx, "lists re-ordered successfully", slog.String("user_id", authContext.UserID))
response.Status(w, http.StatusNoContent)
}
// CreateListItem handles creation of a new list item on a given list // CreateListItem handles creation of a new list item on a given list
// //
// @Summary Create list item // @Summary Create list item
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"git.dittmar.dev/robin/dttmr-api/internal/api/response" "git.dittmar.dev/robin/dttmr-api/internal/api/response"
) )
type VersionResponse struct { type versionResponse struct {
Version string `json:"version"` Version string `json:"version"`
Commit string `json:"commit"` Commit string `json:"commit"`
BuildTime string `json:"buildTime"` BuildTime string `json:"buildTime"`
@@ -19,11 +19,11 @@ type VersionResponse struct {
// @Tags Version // @Tags Version
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {object} VersionResponse // @Success 200 {object} versionResponse
// @Router /version [get] // @Router /version [get]
func VersionHandler(version string, commit string, buildTime string) func(http.ResponseWriter, *http.Request) { func VersionHandler(version string, commit string, buildTime string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
response.JSON(r.Context(), w, http.StatusOK, VersionResponse{ response.JSON(r.Context(), w, http.StatusOK, versionResponse{
Version: version, Version: version,
Commit: commit, Commit: commit,
BuildTime: buildTime, BuildTime: buildTime,
+4
View File
@@ -14,6 +14,10 @@ type RemoveUserFromListPayload struct {
Email string `json:"email"` Email string `json:"email"`
} }
type OrderListsPayload struct {
ListIDs []string `json:"list_ids"`
}
type CreateListItemPayload struct { type CreateListItemPayload struct {
ListID string `json:"list_id"` ListID string `json:"list_id"`
Title string `json:"title"` Title string `json:"title"`
+1
View File
@@ -65,6 +65,7 @@ func NewMux(cfg Config) http.Handler {
apiMux.Handle("GET /lists", protected(listHandler.GetLists)) apiMux.Handle("GET /lists", protected(listHandler.GetLists))
apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList)) apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList))
apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList)) apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList))
apiMux.Handle("POST /lists/order", protected(listHandler.OrderLists))
apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem)) apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem))
apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem)) apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem))
apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem)) apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem))
@@ -0,0 +1,8 @@
BEGIN;
DROP INDEX IF EXISTS idx_list_users_position;
ALTER TABLE IF EXISTS list_users
DROP COLUMN IF EXISTS position;
COMMIT;
@@ -0,0 +1,20 @@
BEGIN;
ALTER TABLE IF EXISTS list_users
ADD COLUMN IF NOT EXISTS position BIGINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_list_users_user_id_position ON list_users (user_id, position);
UPDATE list_users lu
SET position = r.rn - 1
FROM (SELECT list_id,
user_id,
row_number() OVER (
PARTITION BY user_id
ORDER BY created_at, list_id
) AS rn
FROM list_users) r
WHERE lu.list_id = r.list_id
AND lu.user_id = r.user_id;
COMMIT;
+60
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"strings"
"time" "time"
) )
@@ -13,6 +14,7 @@ var (
ErrListItemIDMissing = errors.New("list item id is required") ErrListItemIDMissing = errors.New("list item id is required")
ErrListItemTitleMissing = errors.New("list item title is required") ErrListItemTitleMissing = errors.New("list item title is required")
ErrUserNotInList = errors.New("user not in list") ErrUserNotInList = errors.New("user not in list")
ErrStaleListIDs = errors.New("list ids out of date")
) )
type List struct { type List struct {
@@ -22,6 +24,7 @@ type List struct {
ModifiedAt time.Time `json:"modified_at"` ModifiedAt time.Time `json:"modified_at"`
TotalItems int `json:"total_items"` TotalItems int `json:"total_items"`
CompletedItems int `json:"completed_items"` CompletedItems int `json:"completed_items"`
Position int `json:"position"`
} }
type ListItem struct { type ListItem struct {
@@ -39,6 +42,8 @@ type ListRepository interface {
GetLists(ctx context.Context, userID string) ([]List, error) GetLists(ctx context.Context, userID string) ([]List, error)
AddUserToList(ctx context.Context, listID string, userID string) error AddUserToList(ctx context.Context, listID string, userID string) error
RemoveUserFromList(ctx context.Context, listID string, userID string) error RemoveUserFromList(ctx context.Context, listID string, userID string) error
OrderLists(ctx context.Context, userID string, listIDs []string) error
LockUsersLists(ctx context.Context, userID string) ([]string, error)
IsUserInList(ctx context.Context, listID string, userID string) (bool, error) IsUserInList(ctx context.Context, listID string, userID string) (bool, error)
IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error)
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
@@ -131,6 +136,36 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string,
return s.repo.RemoveUserFromList(ctx, listID, userID) return s.repo.RemoveUserFromList(ctx, listID, userID)
} }
func (s *ListService) OrderLists(ctx context.Context, authUserID string, listIDs []string) error {
if authUserID == "" {
return ErrUserIDMissing
}
if len(listIDs) == 0 {
return ErrListIDMissing
}
return s.tx.WithinTx(ctx, func(ctx context.Context) error {
serverIDs, err := s.repo.LockUsersLists(ctx, authUserID)
if err != nil {
return err
}
if !isPermutation(listIDs, serverIDs) {
slog.ErrorContext(ctx, "no permutation",
slog.Any("client_list_ids", listIDs),
slog.Any("server_list_ids", serverIDs))
return ErrStaleListIDs
}
err = s.repo.OrderLists(ctx, authUserID, listIDs)
if err != nil {
return err
}
return nil
})
}
func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) { func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) {
if listID == "" { if listID == "" {
return nil, ErrListIDMissing return nil, ErrListIDMissing
@@ -257,3 +292,28 @@ func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserI
return nil return nil
} }
func isPermutation(a []string, b []string) bool {
if len(a) != len(b) {
return false
}
aMap := make(map[string]struct{}, len(a))
for _, v := range a {
aMap[strings.ToLower(v)] = struct{}{}
}
seen := make(map[string]struct{}, len(a))
for _, v := range b {
id := strings.ToLower(v)
if _, ok := aMap[id]; !ok {
return false
}
if _, dup := seen[id]; dup {
return false
}
seen[id] = struct{}{}
}
return true
}
+771 -50
View File
@@ -1,4 +1,4 @@
package domain_test package domain
import ( import (
"context" "context"
@@ -6,77 +6,798 @@ import (
"testing" "testing"
"time" "time"
"git.dittmar.dev/robin/dttmr-api/internal/domain" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
) )
type mockListRepo struct { var (
_ Transactor = (*fakeTransactor)(nil)
_ ListRepository = (*mockListRepository)(nil)
)
type txCtxKey struct{}
type fakeTransactor struct {
calls int
err error
}
func (f *fakeTransactor) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error {
f.calls++
if f.err != nil {
return f.err
}
return fn(context.WithValue(ctx, txCtxKey{}, true))
}
var inTx = mock.MatchedBy(func(ctx context.Context) bool {
v, _ := ctx.Value(txCtxKey{}).(bool)
return v
})
type mockListRepository struct {
mock.Mock mock.Mock
} }
func (m *mockListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) { func (m *mockListRepository) CreateList(ctx context.Context, name string) (*List, error) {
args := m.Called(ctx, name, userIDs) args := m.Called(ctx, name)
var list *domain.List list, _ := args.Get(0).(*List)
if l := args.Get(0); l != nil {
list = l.(*domain.List)
}
return list, args.Error(1) return list, args.Error(1)
} }
func TestListService_Create_Success(t *testing.T) { func (m *mockListRepository) DeleteList(ctx context.Context, listID string) error {
expectedList := &domain.List{ args := m.Called(ctx, listID)
ID: "1", return args.Error(0)
Name: "My List", }
CreatedAt: time.Now(),
ModifiedAt: time.Now(), func (m *mockListRepository) GetLists(ctx context.Context, userID string) ([]List, error) {
args := m.Called(ctx, userID)
lists, _ := args.Get(0).([]List)
return lists, args.Error(1)
}
func (m *mockListRepository) AddUserToList(ctx context.Context, listID string, userID string) error {
args := m.Called(ctx, listID, userID)
return args.Error(0)
}
func (m *mockListRepository) RemoveUserFromList(ctx context.Context, listID string, userID string) error {
args := m.Called(ctx, listID, userID)
return args.Error(0)
}
func (m *mockListRepository) OrderLists(ctx context.Context, userID string, listIDs []string) error {
args := m.Called(ctx, userID, listIDs)
return args.Error(0)
}
func (m *mockListRepository) LockUsersLists(ctx context.Context, userID string) ([]string, error) {
args := m.Called(ctx, userID)
ids, _ := args.Get(0).([]string)
return ids, args.Error(1)
}
func (m *mockListRepository) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
args := m.Called(ctx, listID, userID)
return args.Bool(0), args.Error(1)
}
func (m *mockListRepository) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) {
args := m.Called(ctx, listItemID, userID)
return args.Bool(0), args.Error(1)
}
func (m *mockListRepository) CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) {
args := m.Called(ctx, listID, title)
item, _ := args.Get(0).(*ListItem)
return item, args.Error(1)
}
func (m *mockListRepository) DeleteListItem(ctx context.Context, listItemID string) error {
args := m.Called(ctx, listItemID)
return args.Error(0)
}
func (m *mockListRepository) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error {
args := m.Called(ctx, listItemID, title, isCompleted)
return args.Error(0)
}
func (m *mockListRepository) SetListItemTitle(ctx context.Context, listItemID string, title string) error {
args := m.Called(ctx, listItemID, title)
return args.Error(0)
}
func (m *mockListRepository) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error {
args := m.Called(ctx, listItemID, isCompleted)
return args.Error(0)
}
func (m *mockListRepository) GetListItems(ctx context.Context, listID string) ([]ListItem, error) {
args := m.Called(ctx, listID)
items, _ := args.Get(0).([]ListItem)
return items, args.Error(1)
}
func newListService(t *testing.T) (*ListService, *mockListRepository, *fakeTransactor) {
t.Helper()
repo := &mockListRepository{}
repo.Test(t)
t.Cleanup(func() { repo.AssertExpectations(t) })
tx := &fakeTransactor{}
return NewListService(tx, repo), repo, tx
}
func assertCallOrder(t *testing.T, repo *mockListRepository, want ...string) {
t.Helper()
var got []string
for _, c := range repo.Calls {
got = append(got, c.Method)
}
assert.Equal(t, want, got)
}
func TestListService_CreateList(t *testing.T) {
ctx := context.Background()
t.Run("creates the list and adds the creator in one transaction", func(t *testing.T) {
svc, repo, tx := newListService(t)
created := &List{ID: "list-1", Name: "Groceries", CreatedAt: time.Now()}
repo.On("CreateList", inTx, "Groceries").Return(created, nil)
repo.On("AddUserToList", inTx, "list-1", "user-1").Return(nil)
list, err := svc.CreateList(ctx, "user-1", "Groceries")
require.NoError(t, err)
assert.Equal(t, created, list)
assert.Equal(t, 1, tx.calls)
assertCallOrder(t, repo, "CreateList", "AddUserToList")
})
t.Run("insert error aborts before adding the user", func(t *testing.T) {
svc, repo, _ := newListService(t)
repoErr := errors.New("insert failed")
repo.On("CreateList", inTx, "Groceries").Return(nil, repoErr)
list, err := svc.CreateList(ctx, "user-1", "Groceries")
assert.Nil(t, list)
assert.ErrorIs(t, err, repoErr)
assertCallOrder(t, repo, "CreateList")
})
t.Run("membership insert error fails the whole operation", func(t *testing.T) {
svc, repo, _ := newListService(t)
repoErr := errors.New("foreign key violation")
repo.On("CreateList", inTx, "Groceries").Return(&List{ID: "list-1", Name: "Groceries"}, nil)
repo.On("AddUserToList", inTx, "list-1", "user-1").Return(repoErr)
list, err := svc.CreateList(ctx, "user-1", "Groceries")
assert.Nil(t, list, "no half-created list may be returned")
assert.ErrorIs(t, err, repoErr)
})
t.Run("transaction begin error is returned", func(t *testing.T) {
svc, repo, tx := newListService(t)
tx.err = errors.New("could not begin transaction")
list, err := svc.CreateList(ctx, "user-1", "Groceries")
assert.Nil(t, list)
assert.ErrorIs(t, err, tx.err)
assertCallOrder(t, repo)
})
}
func TestListService_DeleteList(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("DeleteList", mock.Anything, "list-1").Return(nil)
err := svc.DeleteList(context.Background(), "user-1", "list-1")
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInList", "DeleteList")
}
func TestListService_GetLists(t *testing.T) {
ctx := context.Background()
t.Run("returns the user's lists", func(t *testing.T) {
svc, repo, _ := newListService(t)
want := []List{{ID: "list-1", Name: "Groceries"}, {ID: "list-2", Name: "Reading"}}
repo.On("GetLists", mock.Anything, "user-1").Return(want, nil)
lists, err := svc.GetLists(ctx, "user-1")
require.NoError(t, err)
assert.Equal(t, want, lists)
})
t.Run("user without lists gets an empty result", func(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("GetLists", mock.Anything, "user-1").Return([]List{}, nil)
lists, err := svc.GetLists(ctx, "user-1")
require.NoError(t, err)
assert.Empty(t, lists)
})
t.Run("repository error is propagated", func(t *testing.T) {
svc, repo, _ := newListService(t)
repoErr := errors.New("connection reset")
repo.On("GetLists", mock.Anything, "user-1").Return(nil, repoErr)
lists, err := svc.GetLists(ctx, "user-1")
assert.Nil(t, lists)
assert.ErrorIs(t, err, repoErr)
})
}
func TestListService_AddUserToList(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("AddUserToList", mock.Anything, "list-1", "user-2").Return(nil)
err := svc.AddUserToList(context.Background(), "user-1", "list-1", "user-2")
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInList", "AddUserToList")
}
func TestListService_RemoveUserFromList(t *testing.T) {
ctx := context.Background()
t.Run("member removes another member", func(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-2").Return(nil)
err := svc.RemoveUserFromList(ctx, "user-1", "list-1", "user-2")
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInList", "RemoveUserFromList")
})
t.Run("member leaves the list", func(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-1").Return(nil)
err := svc.RemoveUserFromList(ctx, "user-1", "list-1", "user-1")
require.NoError(t, err)
})
}
func TestListService_OrderLists(t *testing.T) {
ctx := context.Background()
t.Run("stores the client's order inside one transaction", func(t *testing.T) {
svc, repo, tx := newListService(t)
repo.On("LockUsersLists", inTx, "user-1").Return([]string{"list-a", "list-b", "list-c"}, nil)
repo.On("OrderLists", inTx, "user-1", []string{"list-c", "list-a", "list-b"}).Return(nil)
err := svc.OrderLists(ctx, "user-1", []string{"list-c", "list-a", "list-b"})
require.NoError(t, err)
assert.Equal(t, 1, tx.calls)
assertCallOrder(t, repo, "LockUsersLists", "OrderLists")
})
t.Run("ids are compared case-insensitively", func(t *testing.T) {
svc, repo, _ := newListService(t)
server := []string{
"3f2a8c1e-7d4b-4e21-9a6f-2b1c0d9e8f7a",
"9b1d4e6f-0a2c-4b3d-8e5f-6a7b8c9d0e1f",
}
client := []string{
"9B1D4E6F-0A2C-4B3D-8E5F-6A7B8C9D0E1F",
"3F2A8C1E-7D4B-4E21-9A6F-2B1C0D9E8F7A",
}
repo.On("LockUsersLists", inTx, "user-1").Return(server, nil)
// The client's spelling is passed on unchanged; Postgres' uuid cast
// doesn't care about case.
repo.On("OrderLists", inTx, "user-1", client).Return(nil)
err := svc.OrderLists(ctx, "user-1", client)
require.NoError(t, err)
})
t.Run("stale ids are rejected without writing", func(t *testing.T) {
tests := []struct {
name string
server []string
client []string
}{
{name: "client is missing a list", server: []string{"a", "b", "c"}, client: []string{"a", "b"}},
{name: "client has an extra list", server: []string{"a", "b"}, client: []string{"a", "b", "c"}},
{name: "client has an unknown list", server: []string{"a", "b"}, client: []string{"a", "x"}},
{name: "client repeats a list", server: []string{"a", "b"}, client: []string{"a", "a"}},
{name: "user has no lists anymore", server: []string{}, client: []string{"a"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("LockUsersLists", inTx, "user-1").Return(tt.server, nil)
err := svc.OrderLists(ctx, "user-1", tt.client)
assert.ErrorIs(t, err, ErrStaleListIDs)
assertCallOrder(t, repo, "LockUsersLists")
})
}
})
t.Run("lock error is propagated", func(t *testing.T) {
svc, repo, _ := newListService(t)
lockErr := errors.New("lock timeout")
repo.On("LockUsersLists", inTx, "user-1").Return(nil, lockErr)
err := svc.OrderLists(ctx, "user-1", []string{"list-a"})
assert.ErrorIs(t, err, lockErr)
assert.NotErrorIs(t, err, ErrStaleListIDs)
assertCallOrder(t, repo, "LockUsersLists")
})
t.Run("update error is propagated", func(t *testing.T) {
svc, repo, _ := newListService(t)
updateErr := errors.New("update failed")
repo.On("LockUsersLists", inTx, "user-1").Return([]string{"list-a"}, nil)
repo.On("OrderLists", inTx, "user-1", []string{"list-a"}).Return(updateErr)
err := svc.OrderLists(ctx, "user-1", []string{"list-a"})
assert.ErrorIs(t, err, updateErr)
})
t.Run("transaction begin error is returned", func(t *testing.T) {
svc, repo, tx := newListService(t)
tx.err = errors.New("could not begin transaction")
err := svc.OrderLists(ctx, "user-1", []string{"list-a"})
assert.ErrorIs(t, err, tx.err)
assertCallOrder(t, repo)
})
}
func TestListService_CreateListItem(t *testing.T) {
svc, repo, _ := newListService(t)
created := &ListItem{ID: "item-1", ListID: "list-1", Title: "Milk", CreatedAt: time.Now()}
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("CreateListItem", mock.Anything, "list-1", "Milk").Return(created, nil)
item, err := svc.CreateListItem(context.Background(), "user-1", "list-1", "Milk")
require.NoError(t, err)
assert.Equal(t, created, item)
assertCallOrder(t, repo, "IsUserInList", "CreateListItem")
}
func TestListService_DeleteListItem(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
repo.On("DeleteListItem", mock.Anything, "item-1").Return(nil)
err := svc.DeleteListItem(context.Background(), "user-1", "item-1")
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInListByItemID", "DeleteListItem")
}
func TestListService_UpdateListItem(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
repo.On("UpdateListItem", mock.Anything, "item-1", "Oat milk", true).Return(nil)
err := svc.UpdateListItem(context.Background(), "user-1", "item-1", "Oat milk", true)
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInListByItemID", "UpdateListItem")
}
func TestListService_SetListItemTitle(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
repo.On("SetListItemTitle", mock.Anything, "item-1", "Oat milk").Return(nil)
err := svc.SetListItemTitle(context.Background(), "user-1", "item-1", "Oat milk")
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInListByItemID", "SetListItemTitle")
}
func TestListService_SetListItemCompleted(t *testing.T) {
tests := []struct {
name string
completed bool
}{
{name: "marks the item as completed", completed: true},
{name: "marks the item as open again", completed: false},
} }
repo := new(mockListRepo) for _, tt := range tests {
repo.On("CreateList", mock.Anything, "My List", []string{"user1", "user2"}).Return(expectedList, nil) t.Run(tt.name, func(t *testing.T) {
svc, repo, _ := newListService(t)
//service := domain.NewListService(repo) repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
//list, err := service.CreateList(context.Background(), "My List", []string{"user1", "user2"}) repo.On("SetListItemCompleted", mock.Anything, "item-1", tt.completed).Return(nil)
//require.NoError(t, err) err := svc.SetListItemCompleted(context.Background(), "user-1", "item-1", tt.completed)
//assert.Equal(t, expectedList, list)
//repo.AssertExpectations(t) require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInListByItemID", "SetListItemCompleted")
})
}
} }
func TestListService_Create_EmptyName(t *testing.T) { func TestListService_GetListItems(t *testing.T) {
repo := new(mockListRepo) svc, repo, _ := newListService(t)
//service := domain.NewListService(repo)
//list, err := service.CreateList(context.Background(), "", []string{"user1"}) want := []ListItem{
{ID: "item-1", ListID: "list-1", Title: "Milk"},
{ID: "item-2", ListID: "list-1", Title: "Bread", IsCompleted: true},
}
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("GetListItems", mock.Anything, "list-1").Return(want, nil)
//require.Error(t, err) items, err := svc.GetListItems(context.Background(), "user-1", "list-1")
//assert.EqualError(t, err, "list name must not be empty")
//assert.Nil(t, list) require.NoError(t, err)
repo.AssertExpectations(t) assert.Equal(t, want, items)
assertCallOrder(t, repo, "IsUserInList", "GetListItems")
} }
func TestListService_Create_EmptyUsers(t *testing.T) { type guardedOp struct {
repo := new(mockListRepo) name string
//service := domain.NewListService(repo) // byItem is true when membership is resolved through a list item id
// (IsUserInListByItemID) instead of a list id (IsUserInList).
//list, err := service.CreateList(context.Background(), "My List", []string{}) byItem bool
// expectRepo registers the delegated repository call, returning err.
//require.Error(t, err) expectRepo func(repo *mockListRepository, err error)
//assert.EqualError(t, err, "users must have at least one associated user") // call invokes the service method on behalf of userID.
//assert.Nil(t, list) call func(svc *ListService, userID string) error
repo.AssertExpectations(t)
} }
func TestListService_Create_RepoError(t *testing.T) { func (op guardedOp) guardMethod() string {
expectedErr := errors.New("database error") if op.byItem {
repo := new(mockListRepo) return "IsUserInListByItemID"
repo.On("CreateList", mock.Anything, "My List", []string{"user1"}).Return(nil, expectedErr) }
return "IsUserInList"
}
//service := domain.NewListService(repo) func (op guardedOp) expectGuard(repo *mockListRepository, userID string, inList bool, err error) {
if op.byItem {
repo.On("IsUserInListByItemID", mock.Anything, "item-1", userID).Return(inList, err)
return
}
repo.On("IsUserInList", mock.Anything, "list-1", userID).Return(inList, err)
}
//list, err := service.CreateList(context.Background(), "My List", []string{"user1"}) func guardedOps() []guardedOp {
ctx := context.Background()
//require.Error(t, err) return []guardedOp{
//assert.ErrorIs(t, err, expectedErr) {
//assert.Nil(t, list) name: "DeleteList",
//repo.AssertExpectations(t) expectRepo: func(repo *mockListRepository, err error) {
repo.On("DeleteList", mock.Anything, "list-1").Return(err)
},
call: func(svc *ListService, userID string) error {
return svc.DeleteList(ctx, userID, "list-1")
},
},
{
name: "AddUserToList",
expectRepo: func(repo *mockListRepository, err error) {
repo.On("AddUserToList", mock.Anything, "list-1", "user-2").Return(err)
},
call: func(svc *ListService, userID string) error {
return svc.AddUserToList(ctx, userID, "list-1", "user-2")
},
},
{
name: "RemoveUserFromList",
expectRepo: func(repo *mockListRepository, err error) {
repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-2").Return(err)
},
call: func(svc *ListService, userID string) error {
return svc.RemoveUserFromList(ctx, userID, "list-1", "user-2")
},
},
{
name: "CreateListItem",
expectRepo: func(repo *mockListRepository, err error) {
repo.On("CreateListItem", mock.Anything, "list-1", "Milk").Return(nil, err)
},
call: func(svc *ListService, userID string) error {
item, err := svc.CreateListItem(ctx, userID, "list-1", "Milk")
if item != nil {
return errors.New("expected no item on failure")
}
return err
},
},
{
name: "GetListItems",
expectRepo: func(repo *mockListRepository, err error) {
repo.On("GetListItems", mock.Anything, "list-1").Return(nil, err)
},
call: func(svc *ListService, userID string) error {
items, err := svc.GetListItems(ctx, userID, "list-1")
if items != nil {
return errors.New("expected no items on failure")
}
return err
},
},
{
name: "DeleteListItem",
byItem: true,
expectRepo: func(repo *mockListRepository, err error) {
repo.On("DeleteListItem", mock.Anything, "item-1").Return(err)
},
call: func(svc *ListService, userID string) error {
return svc.DeleteListItem(ctx, userID, "item-1")
},
},
{
name: "UpdateListItem",
byItem: true,
expectRepo: func(repo *mockListRepository, err error) {
repo.On("UpdateListItem", mock.Anything, "item-1", "Oat milk", true).Return(err)
},
call: func(svc *ListService, userID string) error {
return svc.UpdateListItem(ctx, userID, "item-1", "Oat milk", true)
},
},
{
name: "SetListItemTitle",
byItem: true,
expectRepo: func(repo *mockListRepository, err error) {
repo.On("SetListItemTitle", mock.Anything, "item-1", "Oat milk").Return(err)
},
call: func(svc *ListService, userID string) error {
return svc.SetListItemTitle(ctx, userID, "item-1", "Oat milk")
},
},
{
name: "SetListItemCompleted",
byItem: true,
expectRepo: func(repo *mockListRepository, err error) {
repo.On("SetListItemCompleted", mock.Anything, "item-1", true).Return(err)
},
call: func(svc *ListService, userID string) error {
return svc.SetListItemCompleted(ctx, userID, "item-1", true)
},
},
}
}
func TestListService_AccessControl(t *testing.T) {
for _, op := range guardedOps() {
t.Run(op.name, func(t *testing.T) {
t.Run("non-member is refused before any write", func(t *testing.T) {
svc, repo, _ := newListService(t)
op.expectGuard(repo, "intruder", false, nil)
err := op.call(svc, "intruder")
assert.ErrorIs(t, err, ErrUserNotInList)
assertCallOrder(t, repo, op.guardMethod())
})
t.Run("membership check error is propagated, not masked", func(t *testing.T) {
svc, repo, _ := newListService(t)
repoErr := errors.New("connection reset")
op.expectGuard(repo, "user-1", false, repoErr)
err := op.call(svc, "user-1")
assert.ErrorIs(t, err, repoErr)
assert.NotErrorIs(t, err, ErrUserNotInList)
assertCallOrder(t, repo, op.guardMethod())
})
t.Run("repository error is propagated", func(t *testing.T) {
svc, repo, _ := newListService(t)
repoErr := errors.New("write failed")
op.expectGuard(repo, "user-1", true, nil)
op.expectRepo(repo, repoErr)
err := op.call(svc, "user-1")
assert.ErrorIs(t, err, repoErr)
assertCallOrder(t, repo, op.guardMethod(), op.name)
})
})
}
}
func TestListService_ValidationErrors(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
call func(svc *ListService) error
wantErr error
}{
{
name: "CreateList without name",
call: func(svc *ListService) error {
_, err := svc.CreateList(ctx, "user-1", "")
return err
},
wantErr: ErrListNameMissing,
},
{
name: "DeleteList without list id",
call: func(svc *ListService) error { return svc.DeleteList(ctx, "user-1", "") },
wantErr: ErrListIDMissing,
},
{
name: "AddUserToList without list id",
call: func(svc *ListService) error { return svc.AddUserToList(ctx, "user-1", "", "user-2") },
wantErr: ErrListIDMissing,
},
{
name: "AddUserToList without user id",
call: func(svc *ListService) error { return svc.AddUserToList(ctx, "user-1", "list-1", "") },
wantErr: ErrUserIDMissing,
},
{
name: "RemoveUserFromList without list id",
call: func(svc *ListService) error { return svc.RemoveUserFromList(ctx, "user-1", "", "user-2") },
wantErr: ErrListIDMissing,
},
{
name: "RemoveUserFromList without user id",
call: func(svc *ListService) error { return svc.RemoveUserFromList(ctx, "user-1", "list-1", "") },
wantErr: ErrUserIDMissing,
},
{
name: "OrderLists without user id",
call: func(svc *ListService) error { return svc.OrderLists(ctx, "", []string{"list-1"}) },
wantErr: ErrUserIDMissing,
},
{
name: "OrderLists with nil list ids",
call: func(svc *ListService) error { return svc.OrderLists(ctx, "user-1", nil) },
wantErr: ErrListIDMissing,
},
{
name: "OrderLists with empty list ids",
call: func(svc *ListService) error { return svc.OrderLists(ctx, "user-1", []string{}) },
wantErr: ErrListIDMissing,
},
{
name: "CreateListItem without list id",
call: func(svc *ListService) error {
_, err := svc.CreateListItem(ctx, "user-1", "", "Milk")
return err
},
wantErr: ErrListIDMissing,
},
{
name: "CreateListItem without title",
call: func(svc *ListService) error {
_, err := svc.CreateListItem(ctx, "user-1", "list-1", "")
return err
},
wantErr: ErrListItemTitleMissing,
},
{
name: "DeleteListItem without item id",
call: func(svc *ListService) error { return svc.DeleteListItem(ctx, "user-1", "") },
wantErr: ErrListItemIDMissing,
},
{
name: "UpdateListItem without item id",
call: func(svc *ListService) error { return svc.UpdateListItem(ctx, "user-1", "", "Milk", false) },
wantErr: ErrListItemIDMissing,
},
{
name: "UpdateListItem without title",
call: func(svc *ListService) error { return svc.UpdateListItem(ctx, "user-1", "item-1", "", false) },
wantErr: ErrListItemTitleMissing,
},
{
name: "SetListItemTitle without item id",
call: func(svc *ListService) error { return svc.SetListItemTitle(ctx, "user-1", "", "Milk") },
wantErr: ErrListItemIDMissing,
},
{
name: "SetListItemTitle without title",
call: func(svc *ListService) error { return svc.SetListItemTitle(ctx, "user-1", "item-1", "") },
wantErr: ErrListItemTitleMissing,
},
{
name: "SetListItemCompleted without item id",
call: func(svc *ListService) error { return svc.SetListItemCompleted(ctx, "user-1", "", true) },
wantErr: ErrListItemIDMissing,
},
{
name: "GetListItems without list id",
call: func(svc *ListService) error {
_, err := svc.GetListItems(ctx, "user-1", "")
return err
},
wantErr: ErrListIDMissing,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, repo, tx := newListService(t)
err := tt.call(svc)
assert.ErrorIs(t, err, tt.wantErr)
assert.Zero(t, tx.calls, "no transaction may be started")
assertCallOrder(t, repo)
})
}
}
func TestIsPermutation(t *testing.T) {
tests := []struct {
name string
a []string
b []string
want bool
}{
{name: "both empty", a: nil, b: []string{}, want: true},
{name: "same order", a: []string{"a", "b", "c"}, b: []string{"a", "b", "c"}, want: true},
{name: "reordered", a: []string{"a", "b", "c"}, b: []string{"c", "a", "b"}, want: true},
{name: "different case", a: []string{"ABC", "def"}, b: []string{"DEF", "abc"}, want: true},
{name: "first is shorter", a: []string{"a", "b"}, b: []string{"a", "b", "c"}, want: false},
{name: "second is shorter", a: []string{"a", "b", "c"}, b: []string{"a", "b"}, want: false},
{name: "same length, different element", a: []string{"a", "b"}, b: []string{"a", "c"}, want: false},
{name: "duplicate in first", a: []string{"a", "a"}, b: []string{"a", "b"}, want: false},
{name: "duplicate in second", a: []string{"a", "b"}, b: []string{"a", "a"}, want: false},
{name: "same duplicate on both sides", a: []string{"a", "a"}, b: []string{"a", "a"}, want: false},
{name: "duplicate differing only in case", a: []string{"a", "b"}, b: []string{"a", "A"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isPermutation(tt.a, tt.b))
assert.Equal(t, tt.want, isPermutation(tt.b, tt.a), "must be symmetric")
})
}
} }
+38 -2
View File
@@ -38,7 +38,7 @@ func (r *ListRepo) DeleteList(ctx context.Context, listID string) error {
func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) { func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) {
rows, err := r.conn(ctx).QueryContext(ctx, rows, err := r.conn(ctx).QueryContext(ctx,
"SELECT l.id, l.name, l.created_at, l.modified_at, (SELECT COUNT(*) FROM list_items WHERE list_id=l.id), (SELECT COUNT(*) FROM list_items WHERE list_id=l.id AND is_completed=true) FROM lists AS l INNER JOIN list_users ON l.id=list_users.list_id WHERE list_users.user_id = $1", "SELECT l.id, l.name, l.created_at, l.modified_at, (SELECT COUNT(*) FROM list_items WHERE list_id=l.id), (SELECT COUNT(*) FROM list_items WHERE list_id=l.id AND is_completed=true), lu.position FROM lists AS l INNER JOIN list_users AS lu ON l.id=lu.list_id WHERE lu.user_id = $1 ORDER BY lu.position",
userID, userID,
) )
if err != nil { if err != nil {
@@ -52,7 +52,7 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List,
lists := make([]domain.List, 0, 16) lists := make([]domain.List, 0, 16)
for rows.Next() { for rows.Next() {
var l domain.List var l domain.List
err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt, &l.TotalItems, &l.CompletedItems) err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt, &l.TotalItems, &l.CompletedItems, &l.Position)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -87,6 +87,42 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID
return nil return nil
} }
func (r *ListRepo) OrderLists(ctx context.Context, userID string, listIDs []string) error {
_, err := r.conn(ctx).ExecContext(ctx,
"UPDATE list_users AS lu SET position = o.idx - 1 FROM unnest($2::uuid[]) WITH ORDINALITY AS o(list_id, idx) WHERE lu.list_id = o.list_id AND lu.user_id=$1",
userID, listIDs,
)
if err != nil {
return fmt.Errorf("failed to order lists: %w", err)
}
return nil
}
func (r *ListRepo) LockUsersLists(ctx context.Context, userID string) ([]string, error) {
rows, err := r.conn(ctx).QueryContext(ctx,
"SELECT list_id FROM list_users WHERE user_id = $1 FOR UPDATE",
userID,
)
if err != nil {
return nil, fmt.Errorf("failed to lock users lists: %w", err)
}
defer rows.Close()
ids := make([]string, 0, 16)
for rows.Next() {
var listID string
err = rows.Scan(&listID)
if err != nil {
return nil, fmt.Errorf("failed to read list id: %w", err)
}
ids = append(ids, listID)
}
return ids, nil
}
func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) { func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
var cnt int var cnt int
+4 -4
View File
@@ -49,9 +49,9 @@ func (t *Transactor) WithinTx(ctx context.Context, fn func(context.Context) erro
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer func() { _ = tx.Rollback() }()
if err = fn(ctx); err != nil { if err = fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
@@ -64,10 +64,10 @@ func (t *Transactor) withinSavepoint(ctx context.Context, tx *sql.Tx, fn func(co
} }
if err := fn(ctx); err != nil { if err := fn(ctx); err != nil {
_, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT"+name) _, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+name)
return err return err
} }
_, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT"+name) _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+name)
return err return err
} }
+302
View File
@@ -0,0 +1,302 @@
package repository
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type unrelatedKey struct{}
func newTransactor(t *testing.T) (*Transactor, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, mock.ExpectationsWereMet())
_ = db.Close()
})
return NewTransactor(db), mock
}
func TestNewRepo(t *testing.T) {
tr, _ := newTransactor(t)
repo := NewRepo(tr)
assert.Same(t, tr, repo.Transactor)
}
func TestRepo_Conn(t *testing.T) {
t.Run("falls back to the pool without a transaction", func(t *testing.T) {
tr, mock := newTransactor(t)
repo := NewRepo(tr)
mock.ExpectExec("DELETE FROM users WHERE id = $1").
WithArgs("user-1").
WillReturnResult(sqlmock.NewResult(0, 1))
ctx := context.Background()
assert.Same(t, tr.db, repo.conn(ctx))
_, err := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
require.NoError(t, err)
})
t.Run("returns the transaction carried by the context", func(t *testing.T) {
tr, mock := newTransactor(t)
repo := NewRepo(tr)
mock.ExpectBegin()
mock.ExpectRollback()
tx, err := tr.db.BeginTx(context.Background(), nil)
require.NoError(t, err)
ctx := context.WithValue(context.Background(), txKey{}, tx)
assert.Same(t, tx, repo.conn(ctx))
require.NoError(t, tx.Rollback())
})
t.Run("ignores values stored under other keys", func(t *testing.T) {
tr, _ := newTransactor(t)
repo := NewRepo(tr)
ctx := context.WithValue(context.Background(), unrelatedKey{}, "irrelevant")
assert.Same(t, tr.db, repo.conn(ctx))
})
t.Run("ignores a value of the wrong type under txKey", func(t *testing.T) {
tr, _ := newTransactor(t)
repo := NewRepo(tr)
ctx := context.WithValue(context.Background(), txKey{}, "not a transaction")
assert.Same(t, tr.db, repo.conn(ctx))
})
}
func TestRepo_WithinTxIsPromoted(t *testing.T) {
tr, mock := newTransactor(t)
repo := NewRepo(tr)
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM users WHERE id = $1").
WithArgs("user-1").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
err := repo.WithinTx(context.Background(), func(ctx context.Context) error {
_, execErr := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
return execErr
})
require.NoError(t, err)
}
func TestRepo_SiblingReposShareTheTransaction(t *testing.T) {
tr, mock := newTransactor(t)
users := NewRepo(tr)
lists := NewRepo(tr)
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO lists (name) VALUES ($1)").
WithArgs("Groceries").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("DELETE FROM users WHERE id = $1").
WithArgs("user-1").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
assert.Same(t, lists.conn(ctx), users.conn(ctx))
if _, err := lists.conn(ctx).ExecContext(ctx, "INSERT INTO lists (name) VALUES ($1)", "Groceries"); err != nil {
return err
}
_, err := users.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
return err
})
require.NoError(t, err)
}
func TestTransactor_WithinTx(t *testing.T) {
t.Run("commits and routes statements through the tx", func(t *testing.T) {
tr, mock := newTransactor(t)
repo := NewRepo(tr)
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM users WHERE id = $1").
WithArgs("user-1").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
tx, ok := ctx.Value(txKey{}).(*sql.Tx)
require.True(t, ok)
assert.Same(t, tx, repo.conn(ctx))
_, execErr := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
return execErr
})
require.NoError(t, err)
})
t.Run("callback error rolls back and propagates", func(t *testing.T) {
tr, mock := newTransactor(t)
mock.ExpectBegin()
mock.ExpectRollback()
fnErr := errors.New("business rule violated")
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
return fnErr
})
assert.ErrorIs(t, err, fnErr)
})
t.Run("begin error skips the callback", func(t *testing.T) {
tr, mock := newTransactor(t)
beginErr := errors.New("too many connections")
mock.ExpectBegin().WillReturnError(beginErr)
called := false
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
called = true
return nil
})
assert.ErrorIs(t, err, beginErr)
assert.False(t, called)
})
t.Run("commit error is returned", func(t *testing.T) {
tr, mock := newTransactor(t)
commitErr := errors.New("could not serialize access")
mock.ExpectBegin()
mock.ExpectCommit().WillReturnError(commitErr)
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
return nil
})
assert.ErrorIs(t, err, commitErr)
})
}
func TestTransactor_WithinTxNested(t *testing.T) {
t.Run("reuses the outer tx and releases the savepoint", func(t *testing.T) {
tr, mock := newTransactor(t)
mock.ExpectBegin()
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
var outerTx *sql.Tx
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
outerTx = ctx.Value(txKey{}).(*sql.Tx)
return tr.WithinTx(ctx, func(ctx context.Context) error {
assert.Same(t, outerTx, ctx.Value(txKey{}).(*sql.Tx))
return nil
})
})
require.NoError(t, err)
})
t.Run("inner error rolls back to the savepoint", func(t *testing.T) {
tr, mock := newTransactor(t)
mock.ExpectBegin()
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ROLLBACK TO SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectRollback()
innerErr := errors.New("nested failure")
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
return tr.WithinTx(ctx, func(ctx context.Context) error {
return innerErr
})
})
assert.ErrorIs(t, err, innerErr)
})
t.Run("savepoint creation error skips the callback", func(t *testing.T) {
tr, mock := newTransactor(t)
spErr := errors.New("savepoint failed")
mock.ExpectBegin()
mock.ExpectExec("SAVEPOINT sp_1").WillReturnError(spErr)
mock.ExpectRollback()
called := false
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
return tr.WithinTx(ctx, func(ctx context.Context) error {
called = true
return nil
})
})
assert.ErrorIs(t, err, spErr)
assert.False(t, called)
})
t.Run("release error is returned", func(t *testing.T) {
tr, mock := newTransactor(t)
releaseErr := errors.New("no such savepoint")
mock.ExpectBegin()
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnError(releaseErr)
mock.ExpectRollback()
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
return tr.WithinTx(ctx, func(ctx context.Context) error {
return nil
})
})
assert.ErrorIs(t, err, releaseErr)
})
t.Run("each savepoint gets its own name", func(t *testing.T) {
tr, mock := newTransactor(t)
mock.ExpectBegin()
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("SAVEPOINT sp_2").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("RELEASE SAVEPOINT sp_2").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
noop := func(ctx context.Context) error { return nil }
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
if err := tr.WithinTx(ctx, noop); err != nil {
return err
}
return tr.WithinTx(ctx, noop)
})
require.NoError(t, err)
})
}
+185 -162
View File
@@ -1,185 +1,208 @@
package repository package repository
import ( import (
"fmt" "context"
"database/sql"
"errors"
"regexp"
"testing" "testing"
"time" "time"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
)
func newUserRepo(t *testing.T) (*UserRepo, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, mock.ExpectationsWereMet())
_ = db.Close()
})
return &UserRepo{Repo: NewRepo(NewTransactor(db))}, mock
}
const (
insertUserQuery = `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at`
deleteUserQuery = `DELETE FROM users WHERE id = $1`
updatePassQuery = `UPDATE users SET password_hash = $1 WHERE id = $2`
selectUserQuery = `SELECT id, email, name FROM users WHERE email = $1`
) )
func TestUserRepo_CreateUser(t *testing.T) { func TestUserRepo_CreateUser(t *testing.T) {
db, mock, err := sqlmock.New()
assert.NoError(t, err)
defer db.Close()
//repo := NewUserRepo(db)
//ctx := context.Background()
email := "test@example.com"
name := "Test User"
passwordHash := "hashedpassword123"
now := time.Now()
expectedUser := &domain.User{
ID: "1",
Email: email,
Name: name,
CreatedAt: now,
}
t.Run("success", func(t *testing.T) { t.Run("success", func(t *testing.T) {
mock.ExpectBegin() repo, mock := newUserRepo(t)
mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`). createdAt := time.Date(2026, 9, 9, 10, 0, 0, 0, time.UTC)
WithArgs(email, name, passwordHash). mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt)) WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash").
WillReturnRows(
sqlmock.NewRows([]string{"id", "created_at"}).
AddRow("2f1c...", createdAt),
)
mock.ExpectCommit() user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash")
//user, err := repo.CreateUser(ctx, email, name, passwordHash) require.NoError(t, err)
//assert.NoError(t, err) require.NotNil(t, user)
//assert.Equal(t, expectedUser, user) assert.Equal(t, &domain.User{
//assert.NoError(t, mock.ExpectationsWereMet()) ID: "2f1c...",
Email: "robin@dittmar.dev",
Name: "Robin",
CreatedAt: createdAt,
}, user)
}) })
t.Run("begin_tx_error", func(t *testing.T) { t.Run("db error is wrapped", func(t *testing.T) {
mock.ExpectBegin().WillReturnError(fmt.Errorf("tx error")) repo, mock := newUserRepo(t)
//user, err := repo.CreateUser(ctx, email, name, passwordHash) dbErr := errors.New("duplicate key value violates unique constraint")
//assert.Error(t, err) mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)).
//assert.Contains(t, err.Error(), "begin transaction") WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash").
//assert.Nil(t, user) WillReturnError(dbErr)
//assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("insert_error", func(t *testing.T) { user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash")
mock.ExpectBegin()
mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`).
WithArgs(email, name, passwordHash).
WillReturnError(fmt.Errorf("insert error"))
mock.ExpectRollback()
//user, err := repo.CreateUser(ctx, email, name, passwordHash) assert.Nil(t, user)
//assert.Error(t, err) assert.ErrorIs(t, err, dbErr)
//assert.Contains(t, err.Error(), "failed to insert user") assert.ErrorContains(t, err, "failed to insert user")
//assert.Nil(t, user)
//assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("commit_error", func(t *testing.T) {
mock.ExpectBegin()
mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`).
WithArgs(email, name, passwordHash).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt))
mock.ExpectCommit().WillReturnError(fmt.Errorf("commit error"))
//user, err := repo.CreateUser(ctx, email, name, passwordHash)
//assert.Error(t, err)
//assert.Contains(t, err.Error(), "commit transaction")
//assert.Nil(t, user)
//assert.NoError(t, mock.ExpectationsWereMet())
}) })
} }
//func TestUserRepo_CreateUser2(t *testing.T) { func TestUserRepo_DeleteUser(t *testing.T) {
// email := "test@example.com" t.Run("success", func(t *testing.T) {
// name := "Test User" repo, mock := newUserRepo(t)
// passwordHash := "hashedpassword123"
// now := time.Now() mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
// expectedID := "42" WithArgs("user-1").
// WillReturnResult(sqlmock.NewResult(0, 1))
// insertQuery := regexp.QuoteMeta(
// "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at", assert.NoError(t, repo.DeleteUser(context.Background(), "user-1"))
// ) })
//
// testCases := []struct { t.Run("unknown id is not reported", func(t *testing.T) {
// name string repo, mock := newUserRepo(t)
// setupMock func(mock sqlmock.Sqlmock)
// expectedError string mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
// }{ WithArgs("does-not-exist").
// { WillReturnResult(sqlmock.NewResult(0, 0))
// name: "Success: User created perfectly",
// setupMock: func(mock sqlmock.Sqlmock) { assert.NoError(t, repo.DeleteUser(context.Background(), "does-not-exist"))
// mock.ExpectBegin() })
//
// rows := sqlmock.NewRows([]string{"id", "created_at"}). t.Run("db error is wrapped", func(t *testing.T) {
// AddRow(expectedID, now) repo, mock := newUserRepo(t)
//
// mock.ExpectQuery(insertQuery). dbErr := errors.New("connection reset")
// WithArgs(email, name, passwordHash). mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
// WillReturnRows(rows) WithArgs("user-1").
// WillReturnError(dbErr)
// mock.ExpectCommit()
// }, err := repo.DeleteUser(context.Background(), "user-1")
// expectedError: "",
// }, assert.ErrorIs(t, err, dbErr)
// { assert.ErrorContains(t, err, "failed to delete user")
// name: "Failure: Database connection fails on BeginTx", })
// setupMock: func(mock sqlmock.Sqlmock) { }
// mock.ExpectBegin().WillReturnError(errors.New("db connection failed"))
// }, func TestUserRepo_ChangePassword(t *testing.T) {
// expectedError: "begin transaction: db connection failed", t.Run("success", func(t *testing.T) {
// }, repo, mock := newUserRepo(t)
// {
// name: "Failure: Query fails (e.g., duplicate email)", mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)).
// setupMock: func(mock sqlmock.Sqlmock) { WithArgs("$2a$10$newhash", "user-1").
// mock.ExpectBegin() WillReturnResult(sqlmock.NewResult(0, 1))
//
// mock.ExpectQuery(insertQuery). assert.NoError(t, repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash"))
// WithArgs(email, name, passwordHash). })
// WillReturnError(errors.New("unique constraint violation"))
// t.Run("db error is wrapped", func(t *testing.T) {
// mock.ExpectRollback() repo, mock := newUserRepo(t)
// },
// expectedError: "failed to insert user: unique constraint violation", dbErr := errors.New("deadlock detected")
// }, mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)).
// { WithArgs("$2a$10$newhash", "user-1").
// name: "Failure: Commit fails (e.g., network timeout)", WillReturnError(dbErr)
// setupMock: func(mock sqlmock.Sqlmock) {
// mock.ExpectBegin() err := repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash")
//
// rows := sqlmock.NewRows([]string{"id", "created_at"}). assert.ErrorIs(t, err, dbErr)
// AddRow(expectedID, now) assert.ErrorContains(t, err, "failed to update user")
// })
// mock.ExpectQuery(insertQuery). }
// WithArgs(email, name, passwordHash).
// WillReturnRows(rows) func TestUserRepo_UsesTransactionFromContext(t *testing.T) {
// repo, mock := newUserRepo(t)
// mock.ExpectCommit().WillReturnError(errors.New("commit timeout"))
// }, mock.ExpectBegin()
// expectedError: "commit transaction: commit timeout", mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
// }, WithArgs("user-1").
// } WillReturnResult(sqlmock.NewResult(0, 1))
// mock.ExpectCommit()
// for _, tc := range testCases {
// t.Run(tc.name, func(t *testing.T) { tx, err := repo.db.BeginTx(context.Background(), nil)
// db, mock, err := sqlmock.New() require.NoError(t, err)
// require.NoError(t, err)
// defer db.Close() ctx := context.WithValue(context.Background(), txKey{}, tx)
// require.NoError(t, repo.DeleteUser(ctx, "user-1"))
// tc.setupMock(mock) require.NoError(t, tx.Commit())
// }
// //repo := NewUserRepo(db)
// // func TestUserRepo_GetUserByEmail(t *testing.T) {
// //user, err := repo.CreateUser(context.Background(), email, name, passwordHash) t.Run("success", func(t *testing.T) {
// repo, mock := newUserRepo(t)
// if tc.expectedError != "" {
// require.Error(t, err) mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)).
// assert.Contains(t, err.Error(), tc.expectedError) WithArgs("robin@dittmar.dev").
// //assert.Nil(t, user) WillReturnRows(
// } else { sqlmock.NewRows([]string{"id", "email", "name"}).
// require.NoError(t, err) AddRow("user-1", "robin@dittmar.dev", "Robin"),
// //require.NotNil(t, user) )
// //assert.Equal(t, expectedID, user.ID)
// //assert.Equal(t, email, user.Email) user, err := repo.GetUserByEmail(context.Background(), "robin@dittmar.dev")
// //assert.Equal(t, name, user.Name)
// //assert.Equal(t, now, user.CreatedAt) require.NoError(t, err)
// } require.NotNil(t, user)
// assert.Equal(t, "user-1", user.ID)
// assert.NoError(t, mock.ExpectationsWereMet()) assert.Equal(t, "robin@dittmar.dev", user.Email)
// }) assert.Equal(t, "Robin", user.Name)
// } assert.Zero(t, user.CreatedAt) // not selected by this query
//} })
t.Run("not found stays matchable via errors.Is", func(t *testing.T) {
repo, mock := newUserRepo(t)
mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)).
WithArgs("nobody@dittmar.dev").
WillReturnError(sql.ErrNoRows)
user, err := repo.GetUserByEmail(context.Background(), "nobody@dittmar.dev")
assert.Nil(t, user)
assert.ErrorIs(t, err, sql.ErrNoRows)
assert.ErrorContains(t, err, "failed to get user")
})
t.Run("scan error on type mismatch", func(t *testing.T) {
repo, mock := newUserRepo(t)
mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)).
WithArgs("robin@dittmar.dev").
WillReturnRows(
sqlmock.NewRows([]string{"id", "email", "name"}).
AddRow(nil, "robin@dittmar.dev", "Robin"),
)
user, err := repo.GetUserByEmail(context.Background(), "robin@dittmar.dev")
assert.Nil(t, user)
assert.Error(t, err)
})
}