Compare commits

..
1 Commits
Author SHA1 Message Date
robin b44ee7bfe2 feat: change password now requires old/current password 2026-08-29 17:37:52 +02:00
52 changed files with 483 additions and 3328 deletions
-68
View File
@@ -1,68 +0,0 @@
name: Build and Deploy
# Required secrets
# DEPLOY_SSH_KEY - private key for the deploy user
# DEPLOY_HOST - production host, e.g. YOUR_PROD_HOST
# DEPLOY_USER - ssh user, must be in the `docker` group
# DEPLOY_PATH - destination dir, e.g. /opt/dttmr-api
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Install system dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends openssh-client
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
- name: Deploy
env:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts \
"$DEPLOY_USER@$DEPLOY_HOST" \
"cd $DEPLOY_PATH && git pull && docker compose up -d --build"
sync-dev:
needs: build-and-deploy
runs-on: ubuntu-latest
container:
image: node:24-bookworm
permissions:
contents: write
steps:
- name: Install system dependencies
run: apt-get update && apt-get install -y --no-install-recommends git
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fast-forward dev to main
run: |
git fetch origin dev:dev
git checkout dev
git merge --ff-only origin/main
git push origin dev
-45
View File
@@ -1,45 +0,0 @@
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
+2 -7
View File
@@ -48,11 +48,7 @@ run: build-api
## test: Run tests with race detector
test:
@echo "Running tests..."
@if [ "$$(go env GOARCH)" = "amd64" ]; then \
go test -race -timeout 30s ./...; \
else \
go test -timeout 60s ./...; \
fi
@go test -v -race -timeout 30s ./...
## test-cover: Run tests and generate coverage report
test-cover:
@@ -64,8 +60,7 @@ test-cover:
## lint: Run golangci-lint
lint:
@echo "Running linter..."
@#golangci-lint run ./...
@go vet ./...
@golangci-lint run ./...
## fmt: Format code and organize imports
fmt:
+7 -10
View File
@@ -12,11 +12,11 @@ import (
"syscall"
"time"
"git.dittmar.dev/robin/dttmr-api/internal/api/router"
"git.dittmar.dev/robin/dttmr-api/internal/config"
"git.dittmar.dev/robin/dttmr-api/internal/database"
"git.dittmar.dev/robin/dttmr-api/internal/telemetry"
"github.com/joho/godotenv"
"github.com/robindittmar/dttmr-api/internal/api/router"
"github.com/robindittmar/dttmr-api/internal/config"
"github.com/robindittmar/dttmr-api/internal/database"
"github.com/robindittmar/dttmr-api/internal/telemetry"
)
var (
@@ -74,7 +74,7 @@ func run() error {
}
shutdownTelemetry, err := telemetry.Init(context.Background(), telCfg)
if err != nil {
slog.Error("failed to initialize telemetry", slog.Any("error", err))
slog.Error("failed to initialize telemetry", err)
return err
}
defer func() {
@@ -136,11 +136,8 @@ func setupLogging() {
func makeServer(db *sql.DB, cfg *config.Config) *http.Server {
routerConfig := router.Config{
Database: db,
JWTSecret: cfg.JWTSecret,
ServiceVersion: Version,
ServiceCommit: Commit,
ServiceBuildTime: BuildTime,
Database: db,
JWTSecret: cfg.JWTSecret,
}
mux := router.NewMux(routerConfig)
+7 -7
View File
@@ -8,12 +8,12 @@ import (
"os"
"time"
"git.dittmar.dev/robin/dttmr-api/internal/config"
"git.dittmar.dev/robin/dttmr-api/internal/database"
"git.dittmar.dev/robin/dttmr-api/internal/database/migrations"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"git.dittmar.dev/robin/dttmr-api/internal/repository"
"github.com/joho/godotenv"
"github.com/robindittmar/dttmr-api/internal/config"
"github.com/robindittmar/dttmr-api/internal/database"
"github.com/robindittmar/dttmr-api/internal/database/migrations"
"github.com/robindittmar/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/repository"
)
var (
@@ -70,8 +70,8 @@ func main() {
}
func seedAdminUser(db *sql.DB, email string, name string, password string) error {
store := repository.NewStore(db)
userService := domain.NewUserService(store.User)
userRepo := repository.NewUserRepo(db)
userService := domain.NewUserService(userRepo)
_, err := userService.CreateUser(context.Background(), email, name, password)
return err
-1
View File
@@ -1 +0,0 @@
package dttmr_api
+10 -10
View File
@@ -1,12 +1,12 @@
module git.dittmar.dev/robin/dttmr-api
module github.com/robindittmar/dttmr-api
go 1.27
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.20.1
github.com/jackc/pgx/v5 v5.11.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/stretchr/testify v1.12.1
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/metric v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
golang.org/x/crypto v0.57.0
golang.org/x/crypto v0.55.0
)
require (
@@ -37,12 +37,12 @@ require (
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
-162
View File
@@ -1,162 +0,0 @@
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-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/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
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/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/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/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
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/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/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
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/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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
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/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/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/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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
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/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/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/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/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/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/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
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/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/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/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/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/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4 -15
View File
@@ -1,13 +1,12 @@
package handler
import (
"errors"
"log/slog"
"net/http"
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/api/request"
"github.com/robindittmar/dttmr-api/internal/api/response"
"github.com/robindittmar/dttmr-api/internal/domain"
)
type AuthHandler struct {
@@ -28,8 +27,6 @@ func NewAuthHandler(authService *domain.AuthService) *AuthHandler {
// @Param payload body request.LoginPayload true "Login payload"
// @Success 200 {object} domain.TokenPair
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "email not found"
// @Error 401 {object} response.ErrorResponse "password is wrong"
// @Error 500 {object} response.ErrorResponse "failed to login"
// @Router /login [post]
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
@@ -44,15 +41,8 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
tokens, err := h.AuthService.Login(ctx, payload.Email, payload.Password)
if err != nil {
if errors.Is(err, domain.ErrEmailNotFound) {
response.Error(ctx, w, http.StatusUnauthorized, "email not found")
} else if errors.Is(err, domain.ErrPasswordWrong) {
response.Error(ctx, w, http.StatusUnauthorized, "password is wrong")
} else {
response.Error(ctx, w, http.StatusInternalServerError, "failed to login")
}
slog.ErrorContext(ctx, "failed to login", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to login")
return
}
@@ -99,7 +89,6 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
// @Tags Authorization
// @Accept json
// @Produce json
// @Param payload body request.LogoutPayload true "Logout payload"
// @Success 200 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to logout"
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"log/slog"
"net/http"
"github.com/robindittmar/dttmr-api/internal/api/response"
)
type apiResponse struct {
Method string `json:"method"`
Url string `json:"url"`
Proto string `json:"proto"`
Header map[string]string `json:"header"`
Host string `json:"host"`
RemoteAddr string `json:"remoteAddr"`
Form map[string]string `json:"form"`
}
// DefaultHandler handles the default route
//
// @Summary Default route handler
// @Description Default route handler
// @Tags
// @Accept json
// @Produce json
// @Success 200 {object} apiResponse
// @Router / [get]
func DefaultHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
resp := apiResponse{
Method: r.Method,
Url: r.URL.String(),
Proto: r.Proto,
Header: make(map[string]string),
Host: r.Host,
RemoteAddr: r.RemoteAddr,
Form: make(map[string]string),
}
for k, v := range r.Header {
resp.Header[k] = v[0]
}
if err := r.ParseForm(); err == nil {
for k, v := range r.Form {
resp.Form[k] = v[0]
}
} else {
slog.ErrorContext(ctx, "error parsing form", slog.Any("error", err))
}
response.JSON(ctx, w, http.StatusOK, resp)
}
-63
View File
@@ -1,63 +0,0 @@
package handler
import (
"log/slog"
"net/http"
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
)
type ExerciseHandler struct {
ExerciseService *domain.ExerciseService
}
func NewExerciseHandler(exerciseService *domain.ExerciseService) *ExerciseHandler {
return &ExerciseHandler{ExerciseService: exerciseService}
}
// GetExercises handles fetching the list of exercises
//
// @Summary Get exercises route
// @Description Gets a list of all exercises
// @Tags Exercise
// @Accept json
// @Produce json
// @Param page query int false "page"
// @Param count query int false "count"
// @Success 200 {object} response.Paginated[domain.Exercise]
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 400 {object} response.ErrorResponse "invalid value for page"
// @Error 400 {object} response.ErrorResponse "invalid value for count"
// @Error 500 {object} response.ErrorResponse "failed to get exercises"
// @Router /exercises [get]
func (h *ExerciseHandler) GetExercises(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
page, count, err := request.ParsePaginatedQueryParams(r)
if err != nil {
response.Error(ctx, w, http.StatusBadRequest, err.Error())
return
}
exercises, err := h.ExerciseService.GetExercises(ctx, page, count)
if err != nil {
slog.ErrorContext(ctx, "failed to get exercises", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises")
return
}
total, err := h.ExerciseService.CountExercises(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to count exercises", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises")
return
}
response.JSON(ctx, w, http.StatusOK, response.Paginated[domain.Exercise]{
Count: len(exercises),
Total: total,
Data: exercises,
})
}
+1 -1
View File
@@ -3,7 +3,7 @@ package handler
import (
"net/http"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
"github.com/robindittmar/dttmr-api/internal/api/response"
)
type healthResponse struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"git.dittmar.dev/robin/dttmr-api/internal/api/handler"
"github.com/robindittmar/dttmr-api/internal/api/handler"
)
func TestHealthHandler_Success(t *testing.T) {
-208
View File
@@ -1,208 +0,0 @@
package handler
import (
"log/slog"
"net/http"
"strconv"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
)
type InviteHandler struct {
InviteService *domain.InviteService
}
func NewInviteHandler(inviteService *domain.InviteService) *InviteHandler {
return &InviteHandler{InviteService: inviteService}
}
// CreateInvite handles the creation of an invitation
//
// @Summary Create invite route
// @Description Create an invitation, which may be used to register an account
// @Tags Invite
// @Accept json
// @Produce json
// @Success 201 {object} domain.Invite
// @Error 500 {object} response.ErrorResponse "failed to create invite"
// @Router /user/invites [post]
func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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.StatusInternalServerError, "failed to create invite")
return
}
invite, err := h.InviteService.CreateInvite(ctx, authContext.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to create invite", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to create invite")
return
}
slog.InfoContext(ctx, "created invite successfully", slog.String("invite_id", invite.ID))
response.JSON(ctx, w, http.StatusCreated, invite)
}
// DeleteInvite handles the deletion of an invitation
//
// @Summary Delete invitation route
// @Description Deletes an invitation. Invitations can only be deleted when they have not been used
// @Tags Invite
// @Accept json
// @Produce json
// @Param id path int true "Invite ID"
// @Success 204
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 500 {object} response.ErrorResponse "failed to delete invite"
// @Router /user/invites/{id} [delete]
func (h *InviteHandler) DeleteInvite(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
inviteID := r.PathValue("id")
if inviteID == "" {
slog.ErrorContext(ctx, "failed to read invite id from path")
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
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.StatusInternalServerError, "failed to delete invite")
return
}
err = h.InviteService.DeleteInvite(ctx, authContext.UserID, inviteID)
if err != nil {
slog.ErrorContext(ctx, "failed to delete invite", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete invite")
return
}
slog.InfoContext(ctx, "deleted invite successfully", slog.String("invite_id", inviteID))
response.Status(w, http.StatusNoContent)
}
// GetInvites handles fetching the list of a users invitations
//
// @Summary Get invitations route
// @Description Gets a list of all invitations the user has created
// @Tags Invite
// @Accept json
// @Produce json
// @Param page query int false "page"
// @Param count query int false "count"
// @Success 200 {object} response.Paginated[domain.Invite]
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 400 {object} response.ErrorResponse "invalid value for page"
// @Error 400 {object} response.ErrorResponse "invalid value for count"
// @Error 500 {object} response.ErrorResponse "failed to get invites"
// @Router /user/invites [get]
func (h *InviteHandler) GetInvites(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pageStr := r.URL.Query().Get("page")
if pageStr == "" {
pageStr = "1"
}
countStr := r.URL.Query().Get("count")
if countStr == "" {
countStr = "10"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
slog.ErrorContext(ctx,
"failed to read page from query",
slog.String("page", pageStr))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
return
}
count, err := strconv.Atoi(countStr)
if err != nil {
slog.ErrorContext(ctx,
"failed to read count from query",
slog.String("count", countStr))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
return
}
if page < 1 {
slog.ErrorContext(ctx,
"page parameter is invalid",
slog.Int("page", page))
response.Error(ctx, w, http.StatusBadRequest, "invalid value for page")
return
}
if count <= 0 {
slog.ErrorContext(ctx,
"count parameter is invalid",
slog.Int("count", count))
response.Error(ctx, w, http.StatusBadRequest, "invalid value for count")
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.StatusInternalServerError, "failed to get invites")
return
}
invites, err := h.InviteService.GetInvites(ctx, authContext.UserID, page, count)
if err != nil {
slog.ErrorContext(ctx, "failed to get invites", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites")
return
}
total, err := h.InviteService.CountInvites(ctx, authContext.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to count invites", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites")
return
}
response.JSON(ctx, w, http.StatusOK, response.Paginated[domain.Invite]{
Count: len(invites),
Total: total,
Data: invites,
})
}
// GetInvitesStatus handles fetching the counts of a users invitations
//
// @Summary Get invitations status
// @Description Gets active/expired/used counts for all the users invites
// @Tags Invite
// @Accept json
// @Produce json
// @Success 200 {object} domain.InviteCounts
// @Error 500 {object} response.ErrorResponse "failed to count invites"
// @Router /user/invites [get]
func (h *InviteHandler) GetInvitesStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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.StatusInternalServerError, "failed to count invites")
return
}
counts, err := h.InviteService.CountInvitesStructured(ctx, authContext.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to count invites", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to count invites")
return
}
response.JSON(ctx, w, http.StatusOK, counts)
}
+23 -126
View File
@@ -1,13 +1,12 @@
package handler
import (
"errors"
"log/slog"
"net/http"
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/api/request"
"github.com/robindittmar/dttmr-api/internal/api/response"
"github.com/robindittmar/dttmr-api/internal/domain"
)
type ListHandler struct {
@@ -48,14 +47,14 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
return
}
list, err := h.ListService.CreateList(ctx, authContext.UserID, payload.Name)
list, err := h.ListService.CreateList(ctx, authContext.UserID, payload.Name, payload.UserIDs)
if err != nil {
slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to create list")
return
}
slog.InfoContext(ctx, "created list successfully", slog.String("list_id", list.ID))
slog.InfoContext(ctx, "created list successfully", slog.Any("list_id", list.ID))
response.JSON(ctx, w, http.StatusCreated, list)
}
@@ -95,8 +94,8 @@ func (h *ListHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
return
}
slog.InfoContext(ctx, "deleted list successfully", slog.String("list_id", listID))
response.Status(w, http.StatusNoContent)
slog.InfoContext(ctx, "deleted list successfully", slog.Any("list_id", listID))
response.Status(ctx, w, http.StatusNoContent)
}
// GetLists handles fetching lists for the current user
@@ -175,11 +174,8 @@ func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) {
return
}
slog.InfoContext(ctx, "added user to list successfully",
slog.String("list_id", payload.ListID),
slog.String("email", user.Email),
)
response.Status(w, http.StatusNoContent)
slog.InfoContext(ctx, "added user to list successfully", slog.Any("list_id", payload.ListID), slog.Any("email", user.Email))
response.Status(ctx, w, http.StatusNoContent)
}
// RemoveUserFromList handles the removal of a user association to a list
@@ -226,57 +222,8 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request)
return
}
slog.InfoContext(ctx, "removed user from list successfully",
slog.String("list_id", payload.ListID),
slog.String("email", user.Email),
)
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)
slog.InfoContext(ctx, "removed user from list successfully", slog.Any("list_id", payload.ListID), slog.Any("email", user.Email))
response.Status(ctx, w, http.StatusNoContent)
}
// CreateListItem handles creation of a new list item on a given list
@@ -290,7 +237,7 @@ func (h *ListHandler) OrderLists(w http.ResponseWriter, r *http.Request) {
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to create list item"
// @Router /lists/items [post]
// @Router /lists/item [post]
func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -315,7 +262,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
return
}
slog.InfoContext(ctx, "created list item successfully", slog.String("list_item_id", item.ID))
slog.InfoContext(ctx, "created list item successfully", slog.Any("list_item_id", item.ID))
response.JSON(ctx, w, http.StatusCreated, item)
}
@@ -330,7 +277,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
// @Success 204
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 500 {object} response.ErrorResponse "failed to delete list item"
// @Router /lists/items/{id} [delete]
// @Router /lists/item/{id} [delete]
func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -355,8 +302,8 @@ func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) {
return
}
slog.InfoContext(ctx, "deleted list item successfully", slog.String("list_item_id", listItemID))
response.Status(w, http.StatusNoContent)
slog.InfoContext(ctx, "deleted list item successfully", slog.Any("list_item_id", listItemID))
response.Status(ctx, w, http.StatusNoContent)
}
// UpdateListItem handles updating of a list item
@@ -371,7 +318,7 @@ func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) {
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to update list item"
// @Router /lists/items [put]
// @Router /lists/item [put]
func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -396,58 +343,8 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
return
}
slog.InfoContext(ctx, "updated list item successfully", slog.String("list_item_id", payload.ListItemID))
response.Status(w, http.StatusNoContent)
}
// SetListItemTitle handles updating "title" of a list item
//
// @Summary Updates "title" of list item
// @Description Update an existing list item, setting the "title" field
// @Tags List
// @Accept json
// @Produce json
// @Param id path int true "List Item ID"
// @Param payload body request.SetListItemTitlePayload true "Update list item title payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to set list item title"
// @Router /lists/items/{id}/title [post]
func (h *ListHandler) SetListItemTitle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listItemID := r.PathValue("id")
if listItemID == "" {
slog.ErrorContext(ctx, "failed to read list item id from path")
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
return
}
payload, err := request.DecodeJSON[request.SetListItemTitlePayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode set list item title 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.SetListItemTitle(ctx, authContext.UserID, listItemID, payload.Title)
if err != nil {
slog.ErrorContext(ctx, "failed to set list item title", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to set list item title")
return
}
slog.InfoContext(ctx, "update list item title successful", slog.String("list_item_id", listItemID))
response.Status(w, http.StatusNoContent)
slog.InfoContext(ctx, "updated list item successfully", slog.Any("list_item_id", payload.ListItemID))
response.Status(ctx, w, http.StatusNoContent)
}
// SetListItemCompleted handles updating "is_completed" of a list item
@@ -463,8 +360,8 @@ func (h *ListHandler) SetListItemTitle(w http.ResponseWriter, r *http.Request) {
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to set list item completed"
// @Router /lists/items/{id}/complete [post]
// @Error 500 {object} response.ErrorResponse "failed to update list item"
// @Router /lists/items/{id} [post]
func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -496,8 +393,8 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques
return
}
slog.InfoContext(ctx, "update list item completed successful", slog.String("list_item_id", listItemID))
response.Status(w, http.StatusNoContent)
slog.InfoContext(ctx, "updated list item completed successful", slog.Any("list_item_id", listItemID))
response.Status(ctx, w, http.StatusNoContent)
}
// GetListItems handles return all list items of a list
+13 -31
View File
@@ -1,23 +1,21 @@
package handler
import (
"errors"
"log/slog"
"net/http"
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/api/request"
"github.com/robindittmar/dttmr-api/internal/api/response"
"github.com/robindittmar/dttmr-api/internal/domain"
)
type UserHandler struct {
UserService *domain.UserService
AuthService *domain.AuthService
RegistrationService *domain.RegistrationService
UserService *domain.UserService
AuthService *domain.AuthService
}
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, registrationService *domain.RegistrationService) *UserHandler {
return &UserHandler{UserService: userService, AuthService: authService, RegistrationService: registrationService}
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService) *UserHandler {
return &UserHandler{UserService: userService, AuthService: authService}
}
// CreateUser handles the creation of a user
@@ -30,9 +28,6 @@ func NewUserHandler(userService *domain.UserService, authService *domain.AuthSer
// @Param payload body request.CreateUserPayload true "Create user payload"
// @Success 201 {object} domain.User
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 400 {object} response.ErrorResponse "invite is expired"
// @Error 409 {object} response.ErrorResponse "invite is already consumed"
// @Error 400 {object} response.ErrorResponse "invite is invalid"
// @Error 500 {object} response.ErrorResponse "failed to create user"
// @Router /users [post]
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
@@ -45,27 +40,14 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.RegistrationService.Register(ctx, payload.InviteCode, payload.Email, payload.Name, payload.Password)
user, err := h.UserService.CreateUser(ctx, payload.Email, payload.Name, payload.Password)
if err != nil {
slog.ErrorContext(ctx, "failed to register user",
slog.Any("error", err),
slog.Any("payload", payload))
if errors.Is(err, domain.ErrInviteExpired) {
response.Error(ctx, w, http.StatusBadRequest, "invite is expired")
} else if errors.Is(err, domain.ErrInviteConsumed) {
response.Error(ctx, w, http.StatusConflict, "invite is already consumed")
} else if errors.Is(err, domain.ErrInviteInvalid) {
response.Error(ctx, w, http.StatusBadRequest, "invite is invalid")
} else {
response.Error(ctx, w, http.StatusInternalServerError, "failed to register")
}
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to create user")
return
}
slog.InfoContext(ctx, "created user successfully",
slog.String("user_id", user.ID),
)
slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID))
response.JSON(ctx, w, http.StatusCreated, user)
}
@@ -82,7 +64,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
// @Error 401 {object} response.ErrorResponse "could not authenticate with current password"
// @Error 500 {object} response.ErrorResponse "could not get auth context"
// @Error 500 {object} response.ErrorResponse "failed to change password"
// @Router /user/password [post]
// @Router /users/password [post]
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -120,5 +102,5 @@ func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
}
slog.InfoContext(ctx, "password changed successfully", slog.Any("user_id", authContext.UserID))
response.Status(w, http.StatusNoContent)
response.Status(ctx, w, http.StatusNoContent)
}
-32
View File
@@ -1,32 +0,0 @@
package handler
import (
"net/http"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
)
type versionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildTime string `json:"buildTime"`
}
// VersionHandler handles the version route
//
// @Summary Service version
// @Description Reports the version of the API
// @Tags Version
// @Accept json
// @Produce json
// @Success 200 {object} versionResponse
// @Router /version [get]
func VersionHandler(version string, commit string, buildTime string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
response.JSON(r.Context(), w, http.StatusOK, versionResponse{
Version: version,
Commit: commit,
BuildTime: buildTime,
})
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http"
"strings"
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/api/response"
"github.com/robindittmar/dttmr-api/internal/domain"
)
func WithJWT(authService *domain.AuthService) func(http.HandlerFunc) http.HandlerFunc {
-6
View File
@@ -1,6 +0,0 @@
package request
type GetInvitesPayload struct {
Page int
CountPerPage int
}
+2 -9
View File
@@ -1,7 +1,8 @@
package request
type CreateListPayload struct {
Name string `json:"name"`
Name string `json:"name"`
UserIDs []string `json:"user_ids"`
}
type AddUserToListPayload struct {
@@ -14,10 +15,6 @@ type RemoveUserFromListPayload struct {
Email string `json:"email"`
}
type OrderListsPayload struct {
ListIDs []string `json:"list_ids"`
}
type CreateListItemPayload struct {
ListID string `json:"list_id"`
Title string `json:"title"`
@@ -29,10 +26,6 @@ type UpdateListItemPayload struct {
IsCompleted bool `json:"is_completed"`
}
type SetListItemTitlePayload struct {
Title string `json:"title"`
}
type SetListItemCompletedPayload struct {
IsCompleted bool `json:"is_completed"`
}
-58
View File
@@ -1,58 +0,0 @@
package request
import (
"errors"
"log/slog"
"net/http"
"strconv"
)
var (
ErrFailedToDecodeRequestQuery = errors.New("failed to decode request query")
ErrInvalidPageValue = errors.New("invalid value for page")
ErrInvalidCountValue = errors.New("invalid value for count")
)
func ParsePaginatedQueryParams(r *http.Request) (int, int, error) {
ctx := r.Context()
pageStr := r.URL.Query().Get("page")
if pageStr == "" {
pageStr = "1"
}
countStr := r.URL.Query().Get("count")
if countStr == "" {
countStr = "10"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
slog.ErrorContext(ctx,
"failed to read page from query",
slog.String("page", pageStr))
return 0, 0, ErrFailedToDecodeRequestQuery
}
count, err := strconv.Atoi(countStr)
if err != nil {
slog.ErrorContext(ctx,
"failed to read count from query",
slog.String("count", countStr))
return 0, 0, ErrFailedToDecodeRequestQuery
}
if page < 1 {
slog.ErrorContext(ctx,
"page parameter is invalid",
slog.Int("page", page))
return 0, 0, ErrInvalidPageValue
}
if count <= 0 {
slog.ErrorContext(ctx,
"count parameter is invalid",
slog.Int("count", count))
return 0, 0, ErrInvalidCountValue
}
return page, count, nil
}
+3 -4
View File
@@ -1,10 +1,9 @@
package request
type CreateUserPayload struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
InviteCode string `json:"invite_code"`
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
}
type ChangePasswordPayload struct {
-9
View File
@@ -1,9 +0,0 @@
package response
import (
"net/http"
)
func Status(w http.ResponseWriter, status int) {
w.WriteHeader(status)
}
+6 -1
View File
@@ -2,11 +2,16 @@ package response
import (
"context"
"encoding/json/v2"
"encoding/json"
"log/slog"
"net/http"
)
// TODO: Move to own file
func Status(ctx context.Context, w http.ResponseWriter, status int) {
w.WriteHeader(status)
}
func JSON(ctx context.Context, w http.ResponseWriter, status int, data any) {
payload, err := json.Marshal(data)
if err != nil {
-7
View File
@@ -1,7 +0,0 @@
package response
type Paginated[T any] struct {
Count int `json:"count"`
Total int `json:"total"`
Data []T `json:"data"`
}
+23 -46
View File
@@ -4,78 +4,55 @@ import (
"database/sql"
"net/http"
"git.dittmar.dev/robin/dttmr-api/internal/api/handler"
"git.dittmar.dev/robin/dttmr-api/internal/api/middleware"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"git.dittmar.dev/robin/dttmr-api/internal/repository"
"github.com/robindittmar/dttmr-api/internal/api/handler"
"github.com/robindittmar/dttmr-api/internal/api/middleware"
"github.com/robindittmar/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/repository"
)
type Config struct {
Database *sql.DB
JWTSecret string
ServiceVersion string
ServiceCommit string
ServiceBuildTime string
Database *sql.DB
JWTSecret string
}
func NewMux(cfg Config) http.Handler {
store := repository.NewStore(cfg.Database)
authService := domain.NewAuthService(store.Auth, []byte(cfg.JWTSecret))
inviteService := domain.NewInviteService(store.Invite)
userService := domain.NewUserService(store.User)
registrationService := domain.NewRegistrationService(store, userService, inviteService)
listService := domain.NewListService(store, store.List)
exerciseService := domain.NewExerciseService(store.Exercise)
authRepo := repository.NewAuthRepo(cfg.Database)
authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret))
authHandler := handler.NewAuthHandler(authService)
inviteHandler := handler.NewInviteHandler(inviteService)
userHandler := handler.NewUserHandler(userService, authService, registrationService)
userRepo := repository.NewUserRepo(cfg.Database)
userService := domain.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService, authService)
listRepo := repository.NewListRepo(cfg.Database)
listService := domain.NewListService(listRepo)
listHandler := handler.NewListHandler(listService, userService)
exerciseHandler := handler.NewExerciseHandler(exerciseService)
protected := middleware.WithJWT(authService)
apiMux := http.NewServeMux()
apiMux.HandleFunc("GET /version", handler.VersionHandler(
cfg.ServiceVersion, cfg.ServiceCommit, cfg.ServiceBuildTime))
apiMux.HandleFunc("/", handler.DefaultHandler)
apiMux.HandleFunc("GET /health", handler.HealthHandler)
// Auth
apiMux.HandleFunc("POST /login", authHandler.Login)
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
apiMux.HandleFunc("POST /logout", authHandler.Logout)
apiMux.HandleFunc("POST /logout/all", protected(authHandler.LogoutAllDevices))
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
// Users
apiMux.HandleFunc("POST /users", userHandler.CreateUser)
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
apiMux.Handle("POST /users/password", protected(userHandler.ChangePassword))
// User
apiMux.Handle("POST /user/password", protected(userHandler.ChangePassword))
// Invites
apiMux.Handle("POST /user/invites", protected(inviteHandler.CreateInvite))
apiMux.Handle("DELETE /user/invites/{id}", protected(inviteHandler.DeleteInvite))
apiMux.Handle("GET /user/invites", protected(inviteHandler.GetInvites))
apiMux.Handle("GET /user/invites/status", protected(inviteHandler.GetInvitesStatus))
// Lists
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList))
apiMux.Handle("GET /lists", protected(listHandler.GetLists))
apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList))
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("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem))
apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem))
apiMux.Handle("POST /lists/items/{id}/title", protected(listHandler.SetListItemTitle))
apiMux.Handle("POST /lists/items/{id}/complete", protected(listHandler.SetListItemCompleted))
apiMux.Handle("POST /lists/item", protected(listHandler.CreateListItem))
apiMux.Handle("DELETE /lists/item/{id}", protected(listHandler.DeleteListItem))
apiMux.Handle("PUT /lists/item", protected(listHandler.UpdateListItem))
apiMux.Handle("POST /lists/items/{id}", protected(listHandler.SetListItemCompleted))
apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems))
// Exercises
apiMux.Handle("GET /exercises", protected(exerciseHandler.GetExercises))
mux := http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
+1 -4
View File
@@ -51,10 +51,7 @@ func assignIntFromEnv(key string, target *int) {
if val, exists := os.LookupEnv(key); exists {
parsed, err := strconv.Atoi(val)
if err != nil {
slog.Error("failed to parse environment variable",
slog.String("key", key),
slog.String("value", val),
slog.Any("error", err))
slog.Error("failed to parse environment variable", slog.String("var", key), slog.Any("error", err))
} else {
*target = parsed
}
@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inviter_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
invitee_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
code VARCHAR(64) NOT NULL,
code VARCHAR(128) NOT NULL,
consumed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -1,10 +0,0 @@
BEGIN;
ALTER TABLE exercises DROP CONSTRAINT exercises_load_check;
UPDATE exercises SET load = 'absolute' WHERE load = 'external';
ALTER TABLE exercises ADD CONSTRAINT exercises_load_check
CHECK (load IN ('bodyweight', 'absolute'));
COMMIT;
@@ -1,10 +0,0 @@
BEGIN;
ALTER TABLE exercises DROP CONSTRAINT exercises_load_check;
UPDATE exercises SET load = 'external' WHERE load = 'absolute';
ALTER TABLE exercises ADD CONSTRAINT exercises_load_check
CHECK (load IN ('bodyweight', 'external'));
COMMIT;
@@ -1,8 +0,0 @@
BEGIN;
DROP INDEX IF EXISTS idx_list_users_position;
ALTER TABLE IF EXISTS list_users
DROP COLUMN IF EXISTS position;
COMMIT;
@@ -1,20 +0,0 @@
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;
+1 -6
View File
@@ -14,11 +14,6 @@ import (
"golang.org/x/crypto/bcrypt"
)
var (
ErrEmailNotFound = errors.New("email not found")
ErrPasswordWrong = errors.New("password is wrong")
)
type AuthRepository interface {
GetUserById(ctx context.Context, id string) (*AuthUser, error)
GetUserByEmail(ctx context.Context, email string) (*AuthUser, error)
@@ -76,7 +71,7 @@ func (s *AuthService) Authenticate(ctx context.Context, email string, password s
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return user, ErrPasswordWrong
return user, errors.New("invalid email or password")
}
return user, err
}
-41
View File
@@ -1,41 +0,0 @@
package domain
import (
"context"
"time"
)
type ExerciseRepository interface {
CreateExercise(ctx context.Context) (*Exercise, error)
DeleteExercise(ctx context.Context, id string) error
GetExercises(ctx context.Context, offset int, count int) ([]Exercise, error)
CountExercises(ctx context.Context) (int, error)
}
type Exercise struct {
ID string `json:"id"`
Name string `json:"name"`
Equipment []Equipment `json:"equipment"`
Metric Metric `json:"metric"`
Load Load `json:"load"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
ModifiedAt time.Time `json:"modified_at"`
}
type ExerciseService struct {
repo ExerciseRepository
}
func NewExerciseService(r ExerciseRepository) *ExerciseService {
return &ExerciseService{repo: r}
}
func (s *ExerciseService) GetExercises(ctx context.Context, page int, count int) ([]Exercise, error) {
offset := (page - 1) * count
return s.repo.GetExercises(ctx, offset, count)
}
func (s *ExerciseService) CountExercises(ctx context.Context) (int, error) {
return s.repo.CountExercises(ctx)
}
-134
View File
@@ -1,134 +0,0 @@
package domain
import (
"database/sql/driver"
"fmt"
"strings"
)
type Equipment int
const (
EquipmentUnknown Equipment = iota
EquipmentFloor
EquipmentRings
EquipmentPullUpBar
EquipmentParallelBars
EquipmentLowBar
EquipmentParallettes
EquipmentResistanceBand
)
var equipmentNames = [...]string{
EquipmentUnknown: "",
EquipmentFloor: "floor",
EquipmentRings: "rings",
EquipmentPullUpBar: "pull_up_bar",
EquipmentParallelBars: "parallel_bars",
EquipmentLowBar: "low_bar",
EquipmentParallettes: "parallettes",
EquipmentResistanceBand: "resistance_band",
}
var equipmentValues = func() map[string]Equipment {
m := make(map[string]Equipment, len(equipmentNames))
for i, name := range equipmentNames {
m[name] = Equipment(i)
}
return m
}()
func (e Equipment) String() string {
if e < 0 || int(e) > len(equipmentNames) {
return ""
}
return equipmentNames[e]
}
func ParseEquipment(s string) (Equipment, error) {
if e, ok := equipmentValues[s]; ok {
return e, nil
}
return EquipmentUnknown, fmt.Errorf("equipment: unknown value %q", s)
}
//func (e Equipment) MarshalJSON() ([]byte, error) {
// s := e.String()
// if s == "" {
// return nil, fmt.Errorf("equipment: cannot marshal value %d", int(e))
// }
// return json.Marshal(s)
//}
//
//func (e *Equipment) UnmarshalJSON(data []byte) error {
// var s string
// if err := json.Unmarshal(data, &s); err != nil {
// return err
// }
//
// v, err := ParseEquipment(s)
// if err != nil {
// return err
// }
//
// *e = v
// return nil
//}
type EquipmentSet []Equipment
func (s *EquipmentSet) Scan(src any) error {
if src == nil {
*s = nil
return nil
}
var raw string
switch v := src.(type) {
case string:
raw = v
case []byte:
raw = string(v)
default:
return fmt.Errorf("equipment: cannot scan %T", src)
}
names, err := parseTextArray(raw)
if err != nil {
return err
}
out := make(EquipmentSet, len(names))
for i, name := range names {
e, err := ParseEquipment(name)
if err != nil {
return err
}
out[i] = e
}
*s = out
return nil
}
func (s EquipmentSet) Value() (driver.Value, error) {
names := make([]string, len(s))
for i, e := range s {
name := e.String()
if name == "" {
return nil, fmt.Errorf("equipment: cannot store value %d", int(e))
}
names[i] = name
}
return "{" + strings.Join(names, ",") + "}", nil
}
func parseTextArray(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if len(raw) < 2 || raw[0] != '{' || raw[len(raw)-1] != '}' {
return nil, fmt.Errorf("equipment: malformed array %q", raw)
}
if inner := raw[1 : len(raw)-1]; inner != "" {
return strings.Split(inner, ","), nil
}
return nil, nil
}
-95
View File
@@ -1,95 +0,0 @@
package domain
import (
"database/sql/driver"
"fmt"
)
type Load int
const (
LoadUnknown Load = iota
LoadBodyweight
LoadExternal
)
var loadNames = [...]string{
LoadUnknown: "",
LoadBodyweight: "bodyweight",
LoadExternal: "external",
}
var loadValues = func() map[string]Load {
m := make(map[string]Load, len(loadNames))
for i, name := range loadNames {
m[name] = Load(i)
}
return m
}()
func (l Load) String() string {
if l < 0 || int(l) > len(loadNames) {
return ""
}
return loadNames[l]
}
func ParseLoad(s string) (Load, error) {
if l, ok := loadValues[s]; ok {
return l, nil
}
return LoadUnknown, fmt.Errorf("load: unknown value %q", s)
}
//func (l Load) MarshalJSON() ([]byte, error) {
// s := l.String()
// if s == "" {
// return nil, fmt.Errorf("load: cannot marshal value %d", int(l))
// }
// return json.Marshal(s)
//}
//
//func (l *Load) UnmarshalJSON(data []byte) error {
// var s string
// if err := json.Unmarshal(data, &s); err != nil {
// return err
// }
//
// v, err := ParseLoad(s)
// if err != nil {
// return err
// }
//
// *l = v
// return nil
//}
func (l *Load) Scan(src any) error {
var s string
switch v := src.(type) {
case nil:
return fmt.Errorf("load: unexpected NULL")
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("load: cannot scan %T", src)
}
parsed, err := ParseLoad(s)
if err != nil {
return err
}
*l = parsed
return nil
}
func (l Load) Value() (driver.Value, error) {
s := l.String()
if s == "" {
return nil, fmt.Errorf("load: cannot store value %d", int(l))
}
return s, nil
}
-95
View File
@@ -1,95 +0,0 @@
package domain
import (
"database/sql/driver"
"fmt"
)
type Metric int
const (
MetricUnknown Metric = iota
MetricReps
MetricSeconds
)
var metricNames = [...]string{
MetricUnknown: "",
MetricReps: "reps",
MetricSeconds: "seconds",
}
var metricValues = func() map[string]Metric {
m := make(map[string]Metric, len(metricNames))
for i, name := range metricNames {
m[name] = Metric(i)
}
return m
}()
func (m Metric) String() string {
if m < 0 || int(m) > len(metricNames) {
return ""
}
return metricNames[m]
}
func ParseMetric(s string) (Metric, error) {
if m, ok := metricValues[s]; ok {
return m, nil
}
return MetricUnknown, fmt.Errorf("metric: unknown value %q", s)
}
//func (m Metric) MarshalJSON() ([]byte, error) {
// s := m.String()
// if s == "" {
// return nil, fmt.Errorf("metric: cannot marshal value %d", int(m))
// }
// return json.Marshal(s)
//}
//
//func (m *Metric) UnmarshalJSON(data []byte) error {
// var s string
// if err := json.Unmarshal(data, &s); err != nil {
// return err
// }
//
// v, err := ParseMetric(s)
// if err != nil {
// return err
// }
//
// *m = v
// return nil
//}
func (m *Metric) Scan(src any) error {
var s string
switch v := src.(type) {
case nil:
return fmt.Errorf("metric: unexpected NULL")
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("metric: cannot scan %T", src)
}
parsed, err := ParseMetric(s)
if err != nil {
return err
}
*m = parsed
return nil
}
func (m Metric) Value() (driver.Value, error) {
s := m.String()
if s == "" {
return nil, fmt.Errorf("metric: cannot store value %d", int(m))
}
return s, nil
}
-124
View File
@@ -1,124 +0,0 @@
package domain
import (
"context"
"errors"
"time"
)
var (
ErrInviteIDMissing = errors.New("invite id is required")
ErrCodeMissing = errors.New("invite code is required")
ErrInviteInvalid = errors.New("invite is invalid")
ErrInviteExpired = errors.New("invite is expired")
ErrInviteConsumed = errors.New("invite is already consumed")
)
type Invite struct {
ID string `json:"id"`
Code string `json:"code"`
ExpiresAt time.Time `json:"expires_at"`
ConsumedAt *time.Time `json:"consumed_at"`
}
type InviteCounts struct {
Active int `json:"active"`
Expired int `json:"expired"`
Used int `json:"used"`
}
type InviteRepository interface {
CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*Invite, error)
DeleteInvite(ctx context.Context, userID string, inviteID string) error
ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error
GetInvite(ctx context.Context, code string) (*Invite, error)
GetInvites(ctx context.Context, userID string, offset int, count int) ([]Invite, error)
CountInvites(ctx context.Context, userID string) (int, error)
CountInvitesStructured(ctx context.Context, userID string) (*InviteCounts, error)
}
type InviteService struct {
repo InviteRepository
}
func NewInviteService(r InviteRepository) *InviteService {
return &InviteService{repo: r}
}
func (s *InviteService) CreateInvite(ctx context.Context, inviterUserID string) (*Invite, error) {
if inviterUserID == "" {
return nil, ErrUserIDMissing
}
token, err := generateSecureToken(32)
if err != nil {
return nil, err
}
code := hashToken(token)
expiresAt := time.Now().Add(time.Hour * 24 * 7)
return s.repo.CreateInvite(ctx, inviterUserID, code, expiresAt)
}
func (s *InviteService) DeleteInvite(ctx context.Context, userID string, inviteID string) error {
if userID == "" {
return ErrUserIDMissing
}
if inviteID == "" {
return ErrInviteIDMissing
}
return s.repo.DeleteInvite(ctx, userID, inviteID)
}
func (s *InviteService) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error {
if inviteeUserID == "" {
return ErrUserIDMissing
}
return s.repo.ConsumeInvite(ctx, inviteID, inviteeUserID)
}
func (s *InviteService) GetInvite(ctx context.Context, code string) (*Invite, error) {
if code == "" {
return nil, ErrCodeMissing
}
invite, err := s.repo.GetInvite(ctx, code)
if err != nil {
return nil, err
}
if invite.ExpiresAt.Before(time.Now()) {
return nil, ErrInviteExpired
}
if invite.ConsumedAt != nil {
return nil, ErrInviteConsumed
}
return invite, nil
}
func (s *InviteService) GetInvites(ctx context.Context, userID string, page int, countPerPage int) ([]Invite, error) {
if userID == "" {
return nil, ErrUserIDMissing
}
offset := (page - 1) * countPerPage
return s.repo.GetInvites(ctx, userID, offset, countPerPage)
}
func (s *InviteService) CountInvites(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, ErrUserIDMissing
}
return s.repo.CountInvites(ctx, userID)
}
func (s *InviteService) CountInvitesStructured(ctx context.Context, userID string) (*InviteCounts, error) {
if userID == "" {
return nil, ErrUserIDMissing
}
return s.repo.CountInvitesStructured(ctx, userID)
}
+27 -117
View File
@@ -4,17 +4,17 @@ import (
"context"
"errors"
"log/slog"
"strings"
"slices"
"time"
)
var (
ErrListIDMissing = errors.New("list id is required")
ErrListNameMissing = errors.New("list name is required")
ErrListItemIDMissing = errors.New("list item id is required")
ErrListItemTitleMissing = errors.New("list item title is required")
ErrUserNotInList = errors.New("user not in list")
ErrStaleListIDs = errors.New("list ids out of date")
ErrListIDEmpty = errors.New("list id must not be empty")
ErrListNameEmpty = errors.New("list name must not be empty")
ErrUserIDEmpty = errors.New("user id must not be empty")
ErrListItemIDEmpty = errors.New("list item id must not be empty")
ErrListItemTitleEmpty = errors.New("list item title must not be empty")
ErrUserNotInList = errors.New("user not in list")
)
type List struct {
@@ -24,7 +24,6 @@ type List struct {
ModifiedAt time.Time `json:"modified_at"`
TotalItems int `json:"total_items"`
CompletedItems int `json:"completed_items"`
Position int `json:"position"`
}
type ListItem struct {
@@ -37,62 +36,43 @@ type ListItem struct {
}
type ListRepository interface {
CreateList(ctx context.Context, name string) (*List, error)
CreateList(ctx context.Context, name string, userIDs []string) (*List, error)
DeleteList(ctx context.Context, listID string) error
GetLists(ctx context.Context, userID string) ([]List, error)
AddUserToList(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)
IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error)
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
DeleteListItem(ctx context.Context, listItemID string) error
UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error
SetListItemTitle(ctx context.Context, listItemID string, title string) error
SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error
GetListItems(ctx context.Context, listID string) ([]ListItem, error)
}
type ListService struct {
tx Transactor
repo ListRepository
}
func NewListService(tx Transactor, r ListRepository) *ListService {
return &ListService{tx: tx, repo: r}
func NewListService(r ListRepository) *ListService {
return &ListService{repo: r}
}
func (s *ListService) CreateList(ctx context.Context, authUserID string, name string) (*List, error) {
func (s *ListService) CreateList(ctx context.Context, authUserID string, name string, userIDs []string) (*List, error) {
if name == "" {
return nil, ErrListNameMissing
return nil, ErrListNameEmpty
}
var list *List
err := s.tx.WithinTx(ctx, func(ctx context.Context) error {
l, err := s.repo.CreateList(ctx, name)
if err != nil {
return err
}
err = s.repo.AddUserToList(ctx, l.ID, authUserID)
if err != nil {
return err
}
list = l
return nil
})
if err != nil {
return nil, err
if !slices.Contains(userIDs, authUserID) {
userIDs = append(userIDs, authUserID)
}
return list, nil
return s.repo.CreateList(ctx, name, userIDs)
}
func (s *ListService) DeleteList(ctx context.Context, authUserID string, listID string) error {
if listID == "" {
return ErrListIDMissing
return ErrListIDEmpty
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -108,10 +88,10 @@ func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List,
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDMissing
return ErrListIDEmpty
}
if userID == "" {
return ErrUserIDMissing
return ErrUserIDEmpty
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -123,10 +103,10 @@ func (s *ListService) AddUserToList(ctx context.Context, authUserID string, list
func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDMissing
return ErrListIDEmpty
}
if userID == "" {
return ErrUserIDMissing
return ErrUserIDEmpty
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -136,42 +116,12 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string,
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) {
if listID == "" {
return nil, ErrListIDMissing
return nil, ErrListIDEmpty
}
if title == "" {
return nil, ErrListItemTitleMissing
return nil, ErrListItemTitleEmpty
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -183,7 +133,7 @@ func (s *ListService) CreateListItem(ctx context.Context, authUserID string, lis
func (s *ListService) DeleteListItem(ctx context.Context, authUserID string, listItemID string) error {
if listItemID == "" {
return ErrListItemIDMissing
return ErrListItemIDEmpty
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
@@ -195,10 +145,10 @@ func (s *ListService) DeleteListItem(ctx context.Context, authUserID string, lis
func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error {
if listItemID == "" {
return ErrListItemIDMissing
return ErrListItemIDEmpty
}
if title == "" {
return ErrListItemTitleMissing
return ErrListItemTitleEmpty
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
@@ -208,24 +158,9 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis
return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted)
}
func (s *ListService) SetListItemTitle(ctx context.Context, authUserID string, listItemID string, title string) error {
if listItemID == "" {
return ErrListItemIDMissing
}
if title == "" {
return ErrListItemTitleMissing
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
return err
}
return s.repo.SetListItemTitle(ctx, listItemID, title)
}
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
if listItemID == "" {
return ErrListItemIDMissing
return ErrListItemIDEmpty
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
@@ -237,7 +172,7 @@ func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID strin
func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) {
if listID == "" {
return nil, ErrListIDMissing
return nil, ErrListIDEmpty
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -292,28 +227,3 @@ func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserI
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
}
+47 -766
View File
@@ -1,4 +1,4 @@
package domain
package domain_test
import (
"context"
@@ -6,798 +6,79 @@ import (
"testing"
"time"
"github.com/robindittmar/dttmr-api/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
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 {
type mockListRepo struct {
mock.Mock
}
func (m *mockListRepository) CreateList(ctx context.Context, name string) (*List, error) {
args := m.Called(ctx, name)
list, _ := args.Get(0).(*List)
func (m *mockListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) {
args := m.Called(ctx, name, userIDs)
var list *domain.List
if l := args.Get(0); l != nil {
list = l.(*domain.List)
}
return list, args.Error(1)
}
func (m *mockListRepository) DeleteList(ctx context.Context, listID string) error {
args := m.Called(ctx, listID)
return args.Error(0)
}
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)
func TestListService_Create_Success(t *testing.T) {
expectedList := &domain.List{
ID: "1",
Name: "My List",
CreatedAt: time.Now(),
ModifiedAt: time.Now(),
}
assert.Equal(t, want, got)
}
func TestListService_CreateList(t *testing.T) {
ctx := context.Background()
repo := new(mockListRepo)
repo.On("CreateList", mock.Anything, "My List", []string{"user1", "user2"}).Return(expectedList, nil)
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")
service := domain.NewListService(repo)
list, err := service.CreateList(context.Background(), "My List", []string{"user1", "user2"})
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInList", "DeleteList")
assert.Equal(t, expectedList, list)
repo.AssertExpectations(t)
}
func TestListService_GetLists(t *testing.T) {
ctx := context.Background()
func TestListService_Create_EmptyName(t *testing.T) {
repo := new(mockListRepo)
service := domain.NewListService(repo)
t.Run("returns the user's lists", func(t *testing.T) {
svc, repo, _ := newListService(t)
list, err := service.CreateList(context.Background(), "", []string{"user1"})
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)
})
require.Error(t, err)
assert.EqualError(t, err, "list name must not be empty")
assert.Nil(t, list)
repo.AssertExpectations(t)
}
func TestListService_AddUserToList(t *testing.T) {
svc, repo, _ := newListService(t)
func TestListService_Create_EmptyUsers(t *testing.T) {
repo := new(mockListRepo)
service := domain.NewListService(repo)
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("AddUserToList", mock.Anything, "list-1", "user-2").Return(nil)
list, err := service.CreateList(context.Background(), "My List", []string{})
err := svc.AddUserToList(context.Background(), "user-1", "list-1", "user-2")
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInList", "AddUserToList")
require.Error(t, err)
assert.EqualError(t, err, "users must have at least one associated user")
assert.Nil(t, list)
repo.AssertExpectations(t)
}
func TestListService_RemoveUserFromList(t *testing.T) {
ctx := context.Background()
func TestListService_Create_RepoError(t *testing.T) {
expectedErr := errors.New("database error")
repo := new(mockListRepo)
repo.On("CreateList", mock.Anything, "My List", []string{"user1"}).Return(nil, expectedErr)
t.Run("member removes another member", func(t *testing.T) {
svc, repo, _ := newListService(t)
service := domain.NewListService(repo)
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-2").Return(nil)
list, err := service.CreateList(context.Background(), "My List", []string{"user1"})
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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, repo, _ := newListService(t)
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
repo.On("SetListItemCompleted", mock.Anything, "item-1", tt.completed).Return(nil)
err := svc.SetListItemCompleted(context.Background(), "user-1", "item-1", tt.completed)
require.NoError(t, err)
assertCallOrder(t, repo, "IsUserInListByItemID", "SetListItemCompleted")
})
}
}
func TestListService_GetListItems(t *testing.T) {
svc, repo, _ := newListService(t)
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)
items, err := svc.GetListItems(context.Background(), "user-1", "list-1")
require.NoError(t, err)
assert.Equal(t, want, items)
assertCallOrder(t, repo, "IsUserInList", "GetListItems")
}
type guardedOp struct {
name string
// byItem is true when membership is resolved through a list item id
// (IsUserInListByItemID) instead of a list id (IsUserInList).
byItem bool
// expectRepo registers the delegated repository call, returning err.
expectRepo func(repo *mockListRepository, err error)
// call invokes the service method on behalf of userID.
call func(svc *ListService, userID string) error
}
func (op guardedOp) guardMethod() string {
if op.byItem {
return "IsUserInListByItemID"
}
return "IsUserInList"
}
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)
}
func guardedOps() []guardedOp {
ctx := context.Background()
return []guardedOp{
{
name: "DeleteList",
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")
})
}
require.Error(t, err)
assert.ErrorIs(t, err, expectedErr)
assert.Nil(t, list)
repo.AssertExpectations(t)
}
-49
View File
@@ -1,49 +0,0 @@
package domain
import (
"context"
"log/slog"
)
type RegistrationService struct {
tx Transactor
UserService *UserService
InviteService *InviteService
}
func NewRegistrationService(tx Transactor, u *UserService, i *InviteService) *RegistrationService {
return &RegistrationService{tx: tx, UserService: u, InviteService: i}
}
func (s *RegistrationService) Register(ctx context.Context, inviteCode string, email string, username string, password string) (*User, error) {
invite, err := s.InviteService.GetInvite(ctx, inviteCode)
if err != nil {
slog.ErrorContext(ctx, "failed to get invite", slog.Any("error", err))
return nil, err
}
var user *User
err = s.tx.WithinTx(ctx, func(ctx context.Context) error {
user, err = s.UserService.CreateUser(ctx, email, username, password)
if err != nil {
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err))
return err
}
err = s.InviteService.ConsumeInvite(ctx, invite.ID, user.ID)
if err != nil {
slog.ErrorContext(ctx, "failed to consume invite", slog.Any("error", err))
return err
}
return nil
})
if err != nil {
return nil, err
}
slog.InfoContext(ctx, "user registration complete",
slog.String("user_id", user.ID),
slog.String("invite_id", invite.ID))
return user, nil
}
-7
View File
@@ -1,7 +0,0 @@
package domain
import "context"
type Transactor interface {
WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
}
+1 -10
View File
@@ -9,7 +9,7 @@ import (
)
var (
ErrUserIDMissing = errors.New("user id is required")
ErrUserIDMissing = errors.New("user id is missing")
ErrEmailMissing = errors.New("email is required")
ErrNameMissing = errors.New("name is required")
ErrPasswordMissing = errors.New("password is required")
@@ -24,7 +24,6 @@ type User struct {
type UserRepository interface {
CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error)
DeleteUser(ctx context.Context, userID string) error
ChangePassword(ctx context.Context, userID string, passwordHash string) error
GetUserByEmail(ctx context.Context, email string) (*User, error)
}
@@ -56,14 +55,6 @@ func (s *UserService) CreateUser(ctx context.Context, email string, name string,
return s.repo.CreateUser(ctx, email, name, string(hash))
}
func (s *UserService) DeleteUser(ctx context.Context, userID string) error {
if len(userID) == 0 {
return ErrUserIDMissing
}
return s.repo.DeleteUser(ctx, userID)
}
func (s *UserService) ChangePassword(ctx context.Context, userID string, password string) error {
if len(userID) == 0 {
return ErrUserIDMissing
+12 -11
View File
@@ -7,17 +7,21 @@ import (
"fmt"
"time"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/domain"
)
type AuthRepo struct {
Repo
db *sql.DB
}
func NewAuthRepo(db *sql.DB) *AuthRepo {
return &AuthRepo{db: db}
}
func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser, error) {
user := &domain.AuthUser{}
err := r.conn(ctx).QueryRowContext(ctx,
err := r.db.QueryRowContext(ctx,
"SELECT id, email, name, password_hash FROM users WHERE id = $1",
id,
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
@@ -31,14 +35,11 @@ func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser
func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.AuthUser, error) {
user := &domain.AuthUser{}
err := r.conn(ctx).QueryRowContext(ctx,
err := r.db.QueryRowContext(ctx,
"SELECT id, email, name, password_hash FROM users WHERE email = $1",
email,
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrEmailNotFound
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
@@ -46,7 +47,7 @@ func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Au
}
func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error {
_, err := r.conn(ctx).ExecContext(ctx,
_, err := r.db.ExecContext(ctx,
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
userID, tokenHash, expiresAt,
)
@@ -59,7 +60,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa
func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string
err := r.conn(ctx).QueryRowContext(ctx,
err := r.db.QueryRowContext(ctx,
"DELETE FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id",
tokenHash,
).Scan(&userID)
@@ -74,7 +75,7 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s
}
func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash)
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash)
if err != nil {
return fmt.Errorf("failed to revoke refresh token: %w", err)
}
@@ -83,7 +84,7 @@ func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) err
}
func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error {
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID)
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID)
if err != nil {
return fmt.Errorf("failed to revoke refresh tokens: %w", err)
}
-66
View File
@@ -1,66 +0,0 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/jackc/pgx/v5/pgtype"
)
var m = pgtype.NewMap()
type ExerciseRepo struct {
Repo
}
func (r *ExerciseRepo) CreateExercise(ctx context.Context) (*domain.Exercise, error) {
_ = ctx
return nil, nil
}
func (r *ExerciseRepo) DeleteExercise(ctx context.Context, id string) error {
_, _ = ctx, id
return nil
}
func (r *ExerciseRepo) GetExercises(ctx context.Context, offset int, count int) ([]domain.Exercise, error) {
rows, err := r.conn(ctx).QueryContext(ctx,
"SELECT id, name, equipment, metric, load, tags, notes, modified_at FROM exercises WHERE user_id IS NULL ORDER BY modified_at DESC OFFSET $1 LIMIT $2",
offset, count,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to get exercises: %w", err)
}
defer rows.Close()
exercises := make([]domain.Exercise, 0, count)
for rows.Next() {
var e domain.Exercise
err = rows.Scan(&e.ID, &e.Name, (*domain.EquipmentSet)(&e.Equipment), &e.Metric, &e.Load, m.SQLScanner(&e.Tags), &e.Notes, &e.ModifiedAt)
if err != nil {
return nil, err
}
exercises = append(exercises, e)
}
return exercises, nil
}
func (r *ExerciseRepo) CountExercises(ctx context.Context) (int, error) {
var count int
err := r.conn(ctx).QueryRowContext(ctx,
"SELECT COUNT(*) FROM exercises",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count exercises: %w", err)
}
return count, nil
}
-160
View File
@@ -1,160 +0,0 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
)
type InviteRepo struct {
Repo
}
func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*domain.Invite, error) {
var id string
err := r.conn(ctx).QueryRowContext(ctx,
"INSERT INTO invites (inviter_user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id",
inviterUserID, code, expiresAt,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("failed to insert invites: %w", err)
}
return &domain.Invite{
ID: id,
Code: code,
ExpiresAt: expiresAt,
ConsumedAt: nil,
}, nil
}
func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID string) error {
res, err := r.conn(ctx).ExecContext(ctx,
"DELETE FROM invites WHERE id = $1 AND inviter_user_id = $2 AND consumed_at IS NULL",
inviteID, userID,
)
if err != nil {
return fmt.Errorf("failed to delete invite: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("could not get rows affected: %w", err)
}
if affected < 1 {
// It's more of an assumption,
// but unless I encounter this being wrong, I'll keep it.
return domain.ErrInviteConsumed
}
return nil
}
func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error {
res, err := r.conn(ctx).ExecContext(ctx,
"UPDATE invites SET invitee_user_id=$1, consumed_at=NOW() WHERE id=$2 AND expires_at > NOW() AND consumed_at IS NULL",
inviteeUserID, inviteID,
)
if err != nil {
return fmt.Errorf("failed to update invite: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("could not get rows affected: %w", err)
}
if affected < 1 {
return domain.ErrInviteInvalid
}
return nil
}
func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite, error) {
var invite domain.Invite
err := r.conn(ctx).QueryRowContext(ctx,
"SELECT id, code, expires_at, consumed_at FROM invites WHERE code=$1",
code,
).Scan(&invite.ID, &invite.Code, &invite.ExpiresAt, &invite.ConsumedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrInviteInvalid
}
return nil, fmt.Errorf("failed to get invite: %w", err)
}
return &invite, nil
}
func (r *InviteRepo) GetInvites(ctx context.Context, userID string, offset int, count int) ([]domain.Invite, error) {
rows, err := r.conn(ctx).QueryContext(ctx,
"SELECT id, code, expires_at, consumed_at FROM invites WHERE inviter_user_id=$1 ORDER BY created_at DESC OFFSET $2 LIMIT $3",
userID, offset, count,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to get invites: %w", err)
}
defer rows.Close()
var invites []domain.Invite
for rows.Next() {
var i domain.Invite
err = rows.Scan(&i.ID, &i.Code, &i.ExpiresAt, &i.ConsumedAt)
if err != nil {
return nil, err
}
invites = append(invites, i)
}
return invites, nil
}
func (r *InviteRepo) CountInvites(ctx context.Context, userID string) (int, error) {
var count int
err := r.conn(ctx).QueryRowContext(ctx,
"SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1",
userID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count invites: %w", err)
}
return count, nil
}
func (r *InviteRepo) CountInvitesStructured(ctx context.Context, userID string) (*domain.InviteCounts, error) {
var counts domain.InviteCounts
conn := r.conn(ctx)
err := conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1 AND expires_at > NOW() AND consumed_at IS NULL",
userID,
).Scan(&counts.Active)
if err != nil {
return nil, fmt.Errorf("failed to count active invites: %w", err)
}
err = conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1 AND expires_at < NOW() AND consumed_at IS NULL",
userID,
).Scan(&counts.Expired)
if err != nil {
return nil, fmt.Errorf("failed to count expired invites: %w", err)
}
err = conn.QueryRowContext(ctx,
"SELECT COUNT(consumed_at) FROM invites WHERE inviter_user_id=$1",
userID,
).Scan(&counts.Used)
if err != nil {
return nil, fmt.Errorf("failed to count consumed invites: %w", err)
}
return &counts, nil
}
+50 -65
View File
@@ -5,18 +5,29 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/domain"
)
type ListRepo struct {
Repo
db *sql.DB
}
func (r *ListRepo) CreateList(ctx context.Context, name string) (*domain.List, error) {
func NewListRepo(db *sql.DB) *ListRepo {
return &ListRepo{db: db}
}
func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
list := &domain.List{Name: name}
err := r.conn(ctx).QueryRowContext(ctx,
err = tx.QueryRowContext(ctx,
"INSERT INTO lists (name) VALUES ($1) RETURNING id, created_at, modified_at",
name,
).Scan(&list.ID, &list.CreatedAt, &list.ModifiedAt)
@@ -24,11 +35,32 @@ func (r *ListRepo) CreateList(ctx context.Context, name string) (*domain.List, e
return nil, fmt.Errorf("failed to insert list: %w", err)
}
stmt, err := tx.PrepareContext(ctx, "INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)")
if err != nil {
return nil, fmt.Errorf("failed to prepare user/list association statement: %w", err)
}
defer func() {
err := stmt.Close()
if err != nil {
slog.Error("failed to close user/list association statement", slog.Any("error", err))
}
}()
for _, userID := range userIDs {
if _, err = stmt.ExecContext(ctx, list.ID, userID); err != nil {
return nil, fmt.Errorf("failed to insert user/list association: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
return list, nil
}
func (r *ListRepo) DeleteList(ctx context.Context, listID string) error {
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM lists WHERE id = $1", listID)
_, err := r.db.ExecContext(ctx, "DELETE FROM lists WHERE id = $1", listID)
if err != nil {
return fmt.Errorf("failed to delete list: %w", err)
}
@@ -37,8 +69,8 @@ func (r *ListRepo) DeleteList(ctx context.Context, listID string) error {
}
func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) {
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), 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",
rows, err := r.db.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",
userID,
)
if err != nil {
@@ -52,7 +84,7 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List,
lists := make([]domain.List, 0, 16)
for rows.Next() {
var l domain.List
err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt, &l.TotalItems, &l.CompletedItems, &l.Position)
err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt, &l.TotalItems, &l.CompletedItems)
if err != nil {
return nil, err
}
@@ -61,10 +93,11 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List,
}
return lists, nil
}
func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error {
_, err := r.conn(ctx).ExecContext(ctx,
_, err := r.db.ExecContext(ctx,
"INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)",
listID, userID,
)
@@ -76,7 +109,7 @@ func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID stri
}
func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID string) error {
_, err := r.conn(ctx).ExecContext(ctx,
_, err := r.db.ExecContext(ctx,
"DELETE FROM list_users WHERE list_id = $1 AND user_id = $2",
listID, userID,
)
@@ -87,46 +120,10 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID
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) {
var cnt int
err := r.conn(ctx).QueryRowContext(ctx,
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM list_users WHERE list_id = $1 AND user_id = $2",
listID, userID,
).Scan(&cnt)
@@ -140,7 +137,7 @@ func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID strin
func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) {
var cnt int
err := r.conn(ctx).QueryRowContext(ctx,
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM list_users WHERE list_id = (SELECT list_id FROM list_items WHERE id = $1) AND user_id = $2",
listItemID, userID,
).Scan(&cnt)
@@ -154,7 +151,7 @@ func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string,
func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) {
l := &domain.ListItem{Title: title}
err := r.conn(ctx).QueryRowContext(ctx,
err := r.db.QueryRowContext(ctx,
"INSERT INTO list_items (list_id, title) VALUES ($1, $2) RETURNING id, is_completed, created_at, modified_at",
listID, title,
).Scan(&l.ID, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
@@ -162,12 +159,11 @@ func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title stri
return nil, fmt.Errorf("failed to insert list item: %w", err)
}
l.ListID = listID
return l, nil
}
func (r *ListRepo) DeleteListItem(ctx context.Context, listItemID string) error {
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM list_items WHERE id = $1", listItemID)
_, err := r.db.ExecContext(ctx, "DELETE FROM list_items WHERE id = $1", listItemID)
if err != nil {
return fmt.Errorf("failed to delete list item: %w", err)
}
@@ -176,7 +172,7 @@ func (r *ListRepo) DeleteListItem(ctx context.Context, listItemID string) error
}
func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error {
_, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET title = $1, is_completed = $2, modified_at = NOW() WHERE id = $3",
_, err := r.db.ExecContext(ctx, "UPDATE list_items SET title = $1, is_completed = $2, modified_at = NOW() WHERE id = $3",
title, isCompleted, listItemID,
)
if err != nil {
@@ -186,19 +182,8 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title
return nil
}
func (r *ListRepo) SetListItemTitle(ctx context.Context, listItemID string, title string) error {
_, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET title = $1, modified_at = NOW() WHERE id = $2",
title, listItemID,
)
if err != nil {
return fmt.Errorf("failed to update list item title: %w", err)
}
return nil
}
func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error {
_, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2",
_, err := r.db.ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2",
isCompleted, listItemID,
)
if err != nil {
@@ -209,7 +194,7 @@ func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string,
}
func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.ListItem, error) {
rows, err := r.conn(ctx).QueryContext(ctx,
rows, err := r.db.QueryContext(ctx,
"SELECT id, title, is_completed, created_at, modified_at FROM list_items WHERE list_id = $1 ORDER BY is_completed, modified_at DESC",
listID,
)
-73
View File
@@ -1,73 +0,0 @@
package repository
import (
"context"
"database/sql"
"fmt"
"sync/atomic"
)
type Transaction interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
}
type Repo struct {
*Transactor
}
func NewRepo(t *Transactor) Repo {
return Repo{t}
}
func (r *Repo) conn(ctx context.Context) Transaction {
if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
return tx
}
return r.db
}
type txKey struct{}
type Transactor struct {
db *sql.DB
sp atomic.Uint64
}
func NewTransactor(db *sql.DB) *Transactor {
return &Transactor{db: db, sp: atomic.Uint64{}}
}
func (t *Transactor) WithinTx(ctx context.Context, fn func(context.Context) error) error {
if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
return t.withinSavepoint(ctx, tx, fn)
}
tx, err := t.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err = fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
return err
}
return tx.Commit()
}
func (t *Transactor) withinSavepoint(ctx context.Context, tx *sql.Tx, fn func(context.Context) error) error {
name := fmt.Sprintf("sp_%d", t.sp.Add(1))
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+name); err != nil {
return err
}
if err := fn(ctx); err != nil {
_, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+name)
return err
}
_, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+name)
return err
}
-302
View File
@@ -1,302 +0,0 @@
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)
})
}
-25
View File
@@ -1,25 +0,0 @@
package repository
import "database/sql"
type Store struct {
*Transactor
Auth *AuthRepo
Invite *InviteRepo
List *ListRepo
User *UserRepo
Exercise *ExerciseRepo
}
func NewStore(db *sql.DB) *Store {
t := NewTransactor(db)
r := NewRepo(t)
return &Store{
Transactor: t,
Auth: &AuthRepo{r},
Invite: &InviteRepo{r},
List: &ListRepo{r},
User: &UserRepo{r},
Exercise: &ExerciseRepo{r},
}
}
+20 -17
View File
@@ -2,19 +2,30 @@ package repository
import (
"context"
"database/sql"
"fmt"
"git.dittmar.dev/robin/dttmr-api/internal/domain"
"github.com/robindittmar/dttmr-api/internal/domain"
)
type UserRepo struct {
Repo
db *sql.DB
}
func NewUserRepo(db *sql.DB) *UserRepo {
return &UserRepo{db: db}
}
func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, passwordHash string) (*domain.User, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
user := &domain.User{Email: email, Name: name}
err := r.conn(ctx).QueryRowContext(ctx,
err = tx.QueryRowContext(ctx,
"INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at",
email, name, passwordHash,
).Scan(&user.ID, &user.CreatedAt)
@@ -22,23 +33,15 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa
return nil, fmt.Errorf("failed to insert user: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
return user, nil
}
func (r *UserRepo) DeleteUser(ctx context.Context, userID string) error {
_, err := r.conn(ctx).ExecContext(ctx,
"DELETE FROM users WHERE id = $1",
userID,
)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
return nil
}
func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHash string) error {
_, err := r.conn(ctx).ExecContext(ctx,
_, err := r.db.ExecContext(ctx,
"UPDATE users SET password_hash = $1 WHERE id = $2",
passwordHash, userID,
)
@@ -52,7 +55,7 @@ func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHa
func (r *UserRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
user := &domain.User{}
err := r.conn(ctx).QueryRowContext(ctx,
err := r.db.QueryRowContext(ctx,
"SELECT id, email, name FROM users WHERE email = $1",
email,
).Scan(&user.ID, &user.Email, &user.Name)
+164 -183
View File
@@ -2,207 +2,188 @@ package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/robindittmar/dttmr-api/internal/domain"
"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) {
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) {
repo, mock := newUserRepo(t)
mock.ExpectBegin()
createdAt := time.Date(2026, 9, 9, 10, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)).
WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash").
WillReturnRows(
sqlmock.NewRows([]string{"id", "created_at"}).
AddRow("2f1c...", createdAt),
)
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))
user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash")
mock.ExpectCommit()
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, &domain.User{
ID: "2f1c...",
Email: "robin@dittmar.dev",
Name: "Robin",
CreatedAt: createdAt,
}, user)
user, err := repo.CreateUser(ctx, email, name, passwordHash)
assert.NoError(t, err)
assert.Equal(t, expectedUser, user)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("db error is wrapped", func(t *testing.T) {
repo, mock := newUserRepo(t)
t.Run("begin_tx_error", func(t *testing.T) {
mock.ExpectBegin().WillReturnError(fmt.Errorf("tx error"))
dbErr := errors.New("duplicate key value violates unique constraint")
mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)).
WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash").
WillReturnError(dbErr)
user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash")
assert.Nil(t, user)
assert.ErrorIs(t, err, dbErr)
assert.ErrorContains(t, err, "failed to insert user")
})
}
func TestUserRepo_DeleteUser(t *testing.T) {
t.Run("success", func(t *testing.T) {
repo, mock := newUserRepo(t)
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
WithArgs("user-1").
WillReturnResult(sqlmock.NewResult(0, 1))
assert.NoError(t, repo.DeleteUser(context.Background(), "user-1"))
})
t.Run("unknown id is not reported", func(t *testing.T) {
repo, mock := newUserRepo(t)
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
WithArgs("does-not-exist").
WillReturnResult(sqlmock.NewResult(0, 0))
assert.NoError(t, repo.DeleteUser(context.Background(), "does-not-exist"))
})
t.Run("db error is wrapped", func(t *testing.T) {
repo, mock := newUserRepo(t)
dbErr := errors.New("connection reset")
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
WithArgs("user-1").
WillReturnError(dbErr)
err := repo.DeleteUser(context.Background(), "user-1")
assert.ErrorIs(t, err, dbErr)
assert.ErrorContains(t, err, "failed to delete user")
})
}
func TestUserRepo_ChangePassword(t *testing.T) {
t.Run("success", func(t *testing.T) {
repo, mock := newUserRepo(t)
mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)).
WithArgs("$2a$10$newhash", "user-1").
WillReturnResult(sqlmock.NewResult(0, 1))
assert.NoError(t, repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash"))
})
t.Run("db error is wrapped", func(t *testing.T) {
repo, mock := newUserRepo(t)
dbErr := errors.New("deadlock detected")
mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)).
WithArgs("$2a$10$newhash", "user-1").
WillReturnError(dbErr)
err := repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash")
assert.ErrorIs(t, err, dbErr)
assert.ErrorContains(t, err, "failed to update user")
})
}
func TestUserRepo_UsesTransactionFromContext(t *testing.T) {
repo, mock := newUserRepo(t)
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
WithArgs("user-1").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
tx, err := repo.db.BeginTx(context.Background(), nil)
require.NoError(t, err)
ctx := context.WithValue(context.Background(), txKey{}, tx)
require.NoError(t, repo.DeleteUser(ctx, "user-1"))
require.NoError(t, tx.Commit())
}
func TestUserRepo_GetUserByEmail(t *testing.T) {
t.Run("success", 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("user-1", "robin@dittmar.dev", "Robin"),
)
user, err := repo.GetUserByEmail(context.Background(), "robin@dittmar.dev")
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, "user-1", user.ID)
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)
user, err := repo.CreateUser(ctx, email, name, passwordHash)
assert.Error(t, err)
assert.Contains(t, err.Error(), "begin transaction")
assert.Nil(t, user)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("insert_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).
WillReturnError(fmt.Errorf("insert error"))
mock.ExpectRollback()
user, err := repo.CreateUser(ctx, email, name, passwordHash)
assert.Error(t, err)
assert.Contains(t, err.Error(), "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) {
email := "test@example.com"
name := "Test User"
passwordHash := "hashedpassword123"
now := time.Now()
expectedID := "42"
insertQuery := regexp.QuoteMeta(
"INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at",
)
testCases := []struct {
name string
setupMock func(mock sqlmock.Sqlmock)
expectedError string
}{
{
name: "Success: User created perfectly",
setupMock: func(mock sqlmock.Sqlmock) {
mock.ExpectBegin()
rows := sqlmock.NewRows([]string{"id", "created_at"}).
AddRow(expectedID, now)
mock.ExpectQuery(insertQuery).
WithArgs(email, name, passwordHash).
WillReturnRows(rows)
mock.ExpectCommit()
},
expectedError: "",
},
{
name: "Failure: Database connection fails on BeginTx",
setupMock: func(mock sqlmock.Sqlmock) {
mock.ExpectBegin().WillReturnError(errors.New("db connection failed"))
},
expectedError: "begin transaction: db connection failed",
},
{
name: "Failure: Query fails (e.g., duplicate email)",
setupMock: func(mock sqlmock.Sqlmock) {
mock.ExpectBegin()
mock.ExpectQuery(insertQuery).
WithArgs(email, name, passwordHash).
WillReturnError(errors.New("unique constraint violation"))
mock.ExpectRollback()
},
expectedError: "failed to insert user: unique constraint violation",
},
{
name: "Failure: Commit fails (e.g., network timeout)",
setupMock: func(mock sqlmock.Sqlmock) {
mock.ExpectBegin()
rows := sqlmock.NewRows([]string{"id", "created_at"}).
AddRow(expectedID, now)
mock.ExpectQuery(insertQuery).
WithArgs(email, name, passwordHash).
WillReturnRows(rows)
mock.ExpectCommit().WillReturnError(errors.New("commit timeout"))
},
expectedError: "commit transaction: commit timeout",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
tc.setupMock(mock)
repo := NewUserRepo(db)
user, err := repo.CreateUser(context.Background(), email, name, passwordHash)
if tc.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedError)
assert.Nil(t, user)
} else {
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, expectedID, user.ID)
assert.Equal(t, email, user.Email)
assert.Equal(t, name, user.Name)
assert.Equal(t, now, user.CreatedAt)
}
assert.NoError(t, mock.ExpectationsWereMet())
})
}
}