Compare commits
20
Commits
26e5620fc2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee2af480d | ||
|
|
c0ff985061 | ||
|
|
616adbbf75 | ||
|
|
2c97ca7641 | ||
|
|
fd50ce99a7 | ||
|
|
7def43fbeb | ||
|
|
7821009f96 | ||
|
|
c4ce8e66b6 | ||
|
|
a9328043b7 | ||
|
|
3b3afedd64 | ||
|
|
469222054d | ||
|
|
0bb875020b | ||
|
|
726c3554eb | ||
|
|
c8a5da61ef | ||
|
|
77a4ccc520 | ||
|
|
4a967ef002 | ||
|
|
366386c8bf | ||
|
|
f81eb3be96 | ||
|
|
560695c067 | ||
|
|
9d8ed99827 |
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user