diff --git a/.env.example b/.env.example
index d22f177..d75c7da 100644
--- a/.env.example
+++ b/.env.example
@@ -2,7 +2,9 @@ POSTGRES_USER=capstone
POSTGRES_PASSWORD=capstone
POSTGRES_DB=capstone
POSTGRES_PORT=5432
+TEST_POSTGRES_PORT=5433
DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable
+TEST_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${TEST_POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=${DATABASE_URL}
@@ -12,7 +14,7 @@ CLERK_SECRET_KEY=
CLERK_WEBHOOK_SECRET=
NGROK_AUTHTOKEN=
-NGROK_URL=https://cuddly-ladybug-innocent.ngrok-free.app
+NGROK_URL=
APP_ENV=development
-DEV_BYPASS_SECRET=
\ No newline at end of file
+DEV_BYPASS_SECRET=
diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
new file mode 100644
index 0000000..bbea39f
--- /dev/null
+++ b/.github/workflows/pr-validation.yml
@@ -0,0 +1,67 @@
+name: PR Validation
+
+on:
+ pull_request:
+ branches:
+ - development
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ build-fast-tests:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build
+ run: |
+ mkdir -p bin
+ go build -buildvcs=false -o bin/server ./cmd/server/main.go
+
+ - name: Run fast tests
+ run: make test
+
+ integration-postgres:
+ runs-on: ubuntu-latest
+ needs: build-fast-tests
+
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: server_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d server_test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ env:
+ TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/server_test?sslmode=disable
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run integration tests
+ run: make test-integration
diff --git a/Makefile b/Makefile
index 6080d29..7d5ab0d 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: all help build run dev test clean \
+.PHONY: all help build run dev test test-integration clean \
migrations-status migrations-version migrations-create \
migrations-up migrations-down migrations-reset migrations-redo \
migrations-up-to migrations-down-to \
@@ -12,7 +12,8 @@ help:
@echo " build Build the application"
@echo " run Run the application"
@echo " dev Run the application with live reload (docker-compose)"
- @echo " test Run tests"
+ @echo " test Run the fast test suite"
+ @echo " test-integration Run host-side integration tests"
@echo " clean Clean build artifacts"
@echo " migrations-status Show goose migration status"
@echo " migrations-create Create a new migration (make migrations-create name=foo)"
@@ -44,7 +45,10 @@ dev:
# Run tests
test:
- go test ./...
+ go test -count=1 ./... -v
+
+test-integration:
+ go test -count=1 -p 1 -parallel 1 -tags=integration ./... -v
# Clean build artifacts
clean:
@@ -96,4 +100,4 @@ sqlc-gen:
## Validate code without generating
sqlc-vet:
- docker compose exec dev sqlc vet
\ No newline at end of file
+ docker compose exec dev sqlc vet
diff --git a/README.md b/README.md
index e23125d..dad2fe6 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,8 @@ Alternatively, run `make` to list all available commands
make build # Build the application
make run # Run the application
make dev # Run the application with live reload (docker-compose)
-make test # Run tests
+make test # Run the fast test suite
+make test-integration # Run host-side DB-backed integration tests
make clean # Clean build artifacts
# Database & migrations
@@ -168,6 +169,63 @@ make sqlc-gen # Run sqlc generate inside the dev container
make sqlc-vet # Run sqlc vet (validate) inside the dev container
```
+## Testing
+
+The backend test setup uses a Go-friendly hybrid structure:
+
+- Fast tests are colocated beside the packages they cover using `*_test.go`
+- Shared helpers live in `internal/testutil`
+- DB-backed integration helpers live in `internal/testutil/testdb`
+
+At a high level, the test suite is split into two layers:
+
+1. **Fast tests**
+ - Middleware, handler, router, and pure service validation coverage
+ - Run with `go test ./...` or `make test`
+
+2. **Integration tests**
+ - Real Postgres-backed tests for service flows that rely on sqlc queries and transactions
+ - Run with `go test -tags=integration ./...` or `make test-integration`
+ - The integration harness applies the existing Goose migrations into an isolated schema before each test run
+
+### Running the fast suite
+
+```bash
+make test
+```
+
+or
+
+```bash
+go test ./...
+```
+
+### Running integration tests locally
+
+1. Start the local Postgres service:
+
+```bash
+docker compose up -d db
+```
+
+2. Ensure the repo `.env` sets `TEST_DATABASE_URL` to a host-resolvable URL.
+
+Example:
+
+```bash
+POSTGRES_PORT=5432
+TEST_POSTGRES_PORT=5433
+TEST_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${TEST_POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable
+```
+
+3. Run the integration suite.
+
+Default local command:
+
+```bash
+make test-integration
+```
+
## Live Reload for Development
This project supports live reload using [Air](https://github.com/air-verse/air) via Docker. This allows you to automatically rebuild and restart the server when code changes are detected.
@@ -243,4 +301,4 @@ When calling protected endpoints, the following headers are required:
- Go:
- Goose:
- Sqlc:
-- pgx:
\ No newline at end of file
+- pgx:
diff --git a/docker-compose.yml b/docker-compose.yml
index 6371c68..b831685 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,7 @@ services:
image: postgres:16
container_name: postgres_db
env_file: .env
- ports: ["${POSTGRES_PORT:-5432}:5432"]
+ ports: ["${TEST_POSTGRES_PORT:-5433}:5432"]
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
diff --git a/go.mod b/go.mod
index ec419af..4694e9c 100644
--- a/go.mod
+++ b/go.mod
@@ -4,23 +4,33 @@ go 1.25.1
require (
github.com/clerk/clerk-sdk-go/v2 v2.4.2
- github.com/jackc/pgx/v5 v5.7.6
+ github.com/jackc/pgx/v5 v5.8.0
+ github.com/pressly/goose/v3 v3.27.0
+ github.com/stretchr/testify v1.11.1
github.com/svix/svix-webhooks v1.77.0
golang.ngrok.com/ngrok/v2 v2.1.0
)
require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/mfridman/interpolate v0.0.2 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.ngrok.com/muxado/v2 v2.0.1 // indirect
- golang.org/x/crypto v0.37.0 // indirect
- golang.org/x/net v0.30.0 // indirect
- golang.org/x/sync v0.13.0 // indirect
- golang.org/x/text v0.24.0 // indirect
- google.golang.org/protobuf v1.35.1 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/net v0.50.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 7f87c3a..e922f33 100644
--- a/go.sum
+++ b/go.sum
@@ -1,12 +1,16 @@
github.com/clerk/clerk-sdk-go/v2 v2.4.2 h1:TSoYO5zTcNqKhtzx0e31a1UfsBMI2T2TV1mUOTnadBU=
github.com/clerk/clerk-sdk-go/v2 v2.4.2/go.mod h1:VlJ9eDtVdZhugRPbguGJNMVwA7ToFOsXvjtkn20MKjE=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+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/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
@@ -15,21 +19,41 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
-github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
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/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
+github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
+github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
+github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/svix/svix-webhooks v1.77.0 h1:JPHyPZmIh0jY0xCIFL8O8moZhGj7m/KgRXSOfjn1zwU=
github.com/svix/svix-webhooks v1.77.0/go.mod h1:BRbQWn/xdv6zSGULojHza0Yx+hDf+xUJ4s09t3HqJpI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -42,8 +66,10 @@ golang.ngrok.com/ngrok/v2 v2.1.0/go.mod h1:0tZJGx2wKb8HO1IR3hzToPwwI7ggE4nl88/AF
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
-golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
+golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -51,13 +77,13 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
-golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
-golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -66,8 +92,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
-golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -79,16 +105,26 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
-golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
-google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
+modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
+modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
diff --git a/internal/core/service/user_service_integration_test.go b/internal/core/service/user_service_integration_test.go
new file mode 100644
index 0000000..eeb4f5c
--- /dev/null
+++ b/internal/core/service/user_service_integration_test.go
@@ -0,0 +1,39 @@
+//go:build integration
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "server/internal/testutil/testdb"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestHandleCreateUserFromClerkPersistsExpectedFields(t *testing.T) {
+ harness := testdb.New(t)
+
+ payload := json.RawMessage(`{
+ "id": "clerk_user_123",
+ "first_name": "Ada",
+ "last_name": "Lovelace",
+ "email_addresses": [
+ {"email_address": "ada@example.com", "id": "email_1"},
+ {"email_address": "other@example.com", "id": "email_2"}
+ ],
+ "image_url": "https://example.com/ada.png"
+ }`)
+
+ err := HandleCreateUserFromClerk(context.Background(), harness.Store, payload)
+ require.NoError(t, err)
+
+ user, err := harness.Store.Queries.GetUserByClerkID(context.Background(), "clerk_user_123")
+ require.NoError(t, err)
+ require.Equal(t, "Ada", user.FirstName)
+ require.Equal(t, "Lovelace", user.LastName)
+ require.Equal(t, "ada@example.com", user.Email)
+ require.True(t, user.ImageUrl.Valid)
+ require.Equal(t, "https://example.com/ada.png", user.ImageUrl.String)
+}
diff --git a/internal/core/service/user_service_test.go b/internal/core/service/user_service_test.go
new file mode 100644
index 0000000..1da971d
--- /dev/null
+++ b/internal/core/service/user_service_test.go
@@ -0,0 +1,20 @@
+//go:build !integration
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestHandleCreateUserFromClerkReturnsErrorOnMalformedJSON(t *testing.T) {
+ t.Parallel()
+
+ err := HandleCreateUserFromClerk(context.Background(), nil, json.RawMessage(`{"id":`))
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "failed to parse user data")
+}
diff --git a/internal/core/service/workout/workout_session_integration_test.go b/internal/core/service/workout/workout_session_integration_test.go
new file mode 100644
index 0000000..66eb0cd
--- /dev/null
+++ b/internal/core/service/workout/workout_session_integration_test.go
@@ -0,0 +1,161 @@
+//go:build integration
+
+package workout
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "server/internal/testutil/testdb"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateWorkoutSessionPersistsCalibrationValues(t *testing.T) {
+ harness := testdb.New(t)
+ user := testdb.CreateUser(t, harness.Store, "create_session")
+ workoutType := testdb.CreateWorkoutType(t, harness.Store, "create_session")
+ testdb.UpsertCalibration(t, harness.Store, user.ID, 1.25, 2.5, 3.75)
+
+ startTime := time.Date(2026, 3, 30, 8, 0, 0, 0, time.UTC)
+ session, err := CreateWorkoutSession(context.Background(), harness.Store, user.ID, CreateWorkoutSessionDTO{
+ WorkoutTypeID: workoutType.ID,
+ StartTime: &startTime,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, user.ID, session.UserID)
+ require.Equal(t, workoutType.ID, session.WorkoutTypeID)
+ require.True(t, session.CalibrationYawAngle.Valid)
+ require.True(t, session.CalibrationPitchAngle.Valid)
+ require.True(t, session.CalibrationRollAngle.Valid)
+ require.Equal(t, 1.25, session.CalibrationYawAngle.Float64)
+ require.Equal(t, 2.5, session.CalibrationPitchAngle.Float64)
+ require.Equal(t, 3.75, session.CalibrationRollAngle.Float64)
+ require.True(t, session.StartTime.Valid)
+ require.True(t, session.StartTime.Time.Equal(startTime))
+ require.False(t, session.EndTime.Valid)
+}
+
+func TestGetWorkoutSessionByIDReturnsHydratedSetsAndReps(t *testing.T) {
+ harness := testdb.New(t)
+ user := testdb.CreateUser(t, harness.Store, "get_by_id")
+ workoutType := testdb.CreateWorkoutType(t, harness.Store, "get_by_id")
+
+ session, err := CreateWorkoutSession(context.Background(), harness.Store, user.ID, CreateWorkoutSessionDTO{
+ WorkoutTypeID: workoutType.ID,
+ })
+ require.NoError(t, err)
+
+ _, err = CreateWorkoutSessionSet(context.Background(), harness.Store, user.ID, session.ID, CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ {
+ RepNumber: 1,
+ StartTime: 10,
+ EndTime: 20,
+ Samples: []byte(`[{"angle": 90}]`),
+ Metrics: []byte(`{"velocity": 1.5}`),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ details, err := GetWorkoutSessionByID(context.Background(), harness.Store, user.ID, session.ID)
+ require.NoError(t, err)
+ require.Equal(t, session.ID, details.ID)
+ require.Len(t, details.Sets, 1)
+ require.Equal(t, int32(1), details.Sets[0].SetNumber)
+ require.Len(t, details.Sets[0].Reps, 1)
+ require.Equal(t, int32(1), details.Sets[0].Reps[0].RepNumber)
+
+ samples, ok := details.Sets[0].Reps[0].Samples.([]any)
+ require.True(t, ok)
+ require.Len(t, samples, 1)
+
+ metrics, ok := details.Sets[0].Reps[0].Metrics.(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, 1.5, metrics["velocity"])
+}
+
+func TestGetUserWorkoutSessionHistoryReturnsOrderedCounts(t *testing.T) {
+ harness := testdb.New(t)
+ user := testdb.CreateUser(t, harness.Store, "history")
+ workoutType := testdb.CreateWorkoutType(t, harness.Store, "history")
+
+ olderStart := time.Date(2026, 3, 29, 8, 0, 0, 0, time.UTC)
+ olderSession, err := CreateWorkoutSession(context.Background(), harness.Store, user.ID, CreateWorkoutSessionDTO{
+ WorkoutTypeID: workoutType.ID,
+ StartTime: &olderStart,
+ })
+ require.NoError(t, err)
+
+ _, err = CreateWorkoutSessionSet(context.Background(), harness.Store, user.ID, olderSession.ID, CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ {
+ RepNumber: 1,
+ StartTime: 1,
+ EndTime: 2,
+ Samples: []byte(`[]`),
+ Metrics: []byte(`{}`),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ newerStart := time.Date(2026, 3, 30, 8, 0, 0, 0, time.UTC)
+ newerSession, err := CreateWorkoutSession(context.Background(), harness.Store, user.ID, CreateWorkoutSessionDTO{
+ WorkoutTypeID: workoutType.ID,
+ StartTime: &newerStart,
+ })
+ require.NoError(t, err)
+
+ _, err = CreateWorkoutSessionSet(context.Background(), harness.Store, user.ID, newerSession.ID, CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ {
+ RepNumber: 1,
+ StartTime: 3,
+ EndTime: 4,
+ Samples: []byte(`[]`),
+ Metrics: []byte(`{}`),
+ },
+ {
+ RepNumber: 2,
+ StartTime: 5,
+ EndTime: 6,
+ Samples: []byte(`[]`),
+ Metrics: []byte(`{}`),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ _, err = CreateWorkoutSessionSet(context.Background(), harness.Store, user.ID, newerSession.ID, CreateWorkoutSessionSetDTO{
+ SetNumber: 2,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ {
+ RepNumber: 1,
+ StartTime: 7,
+ EndTime: 8,
+ Samples: []byte(`[]`),
+ Metrics: []byte(`{}`),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ history, err := GetUserWorkoutSessionHistory(context.Background(), harness.Store, user.ID, 10)
+ require.NoError(t, err)
+ require.Len(t, history, 2)
+
+ require.Equal(t, newerSession.ID, history[0].ID)
+ require.Equal(t, int32(2), history[0].TotalSets)
+ require.Equal(t, int32(3), history[0].TotalReps)
+
+ require.Equal(t, olderSession.ID, history[1].ID)
+ require.Equal(t, int32(1), history[1].TotalSets)
+ require.Equal(t, int32(1), history[1].TotalReps)
+}
diff --git a/internal/core/service/workout/workout_session_set.service.go b/internal/core/service/workout/workout_session_set.service.go
index 453b6bf..5f9fd0f 100644
--- a/internal/core/service/workout/workout_session_set.service.go
+++ b/internal/core/service/workout/workout_session_set.service.go
@@ -18,28 +18,51 @@ type CreateWorkoutSessionSetRepDTO struct {
Metrics json.RawMessage `json:"metrics"`
}
-type CreateWorkoutSessionSetDTO struct {
- SetNumber int32 `json:"set_number"`
- StartTime *time.Time `json:"start_time"`
- EndTime *time.Time `json:"end_time"`
- Reps []CreateWorkoutSessionSetRepDTO `json:"reps"`
-}
-
-func CreateWorkoutSessionSet(ctx context.Context, store *data.Store, userID string, sessionID string, dto CreateWorkoutSessionSetDTO) (db.WorkoutSessionSet, error) {
- session, err := store.Queries.GetWorkoutSessionByID(ctx, sessionID)
- if err != nil {
- return db.WorkoutSessionSet{}, err
- }
-
- if session.UserID != userID {
- return db.WorkoutSessionSet{}, fmt.Errorf("forbidden")
- }
-
- if dto.SetNumber <= 0 {
- return db.WorkoutSessionSet{}, fmt.Errorf("set_number must be greater than 0")
+type CreateWorkoutSessionSetDTO struct {
+ SetNumber int32 `json:"set_number"`
+ StartTime *time.Time `json:"start_time"`
+ EndTime *time.Time `json:"end_time"`
+ Reps []CreateWorkoutSessionSetRepDTO `json:"reps"`
+}
+
+func validateCreateWorkoutSessionSetDTO(dto CreateWorkoutSessionSetDTO) error {
+ if dto.SetNumber <= 0 {
+ return fmt.Errorf("set_number must be greater than 0")
+ }
+
+ for _, rep := range dto.Reps {
+ if rep.RepNumber <= 0 {
+ return fmt.Errorf("rep_number must be greater than 0")
+ }
+ if rep.EndTime < rep.StartTime {
+ return fmt.Errorf("rep end_time must be after start_time")
+ }
+ if !json.Valid(rep.Samples) {
+ return fmt.Errorf("samples must be valid JSON")
+ }
+ if !json.Valid(rep.Metrics) {
+ return fmt.Errorf("metrics must be valid JSON")
+ }
+ }
+
+ return nil
+}
+
+func CreateWorkoutSessionSet(ctx context.Context, store *data.Store, userID string, sessionID string, dto CreateWorkoutSessionSetDTO) (db.WorkoutSessionSet, error) {
+ if err := validateCreateWorkoutSessionSetDTO(dto); err != nil {
+ return db.WorkoutSessionSet{}, err
+ }
+
+ session, err := store.Queries.GetWorkoutSessionByID(ctx, sessionID)
+ if err != nil {
+ return db.WorkoutSessionSet{}, err
}
- var createdSet db.WorkoutSessionSet
+ if session.UserID != userID {
+ return db.WorkoutSessionSet{}, fmt.Errorf("forbidden")
+ }
+
+ var createdSet db.WorkoutSessionSet
startTime := time.Now()
if dto.StartTime != nil && !dto.StartTime.IsZero() {
@@ -57,26 +80,13 @@ func CreateWorkoutSessionSet(ctx context.Context, store *data.Store, userID stri
StartTime: helpers.NewNullTime(&startTime),
EndTime: helpers.NewNullTime(&endTime),
})
- if err != nil {
- return err
- }
-
- for _, rep := range dto.Reps {
- if rep.RepNumber <= 0 {
- return fmt.Errorf("rep_number must be greater than 0")
- }
- if rep.EndTime < rep.StartTime {
- return fmt.Errorf("rep end_time must be after start_time")
- }
- if !json.Valid(rep.Samples) {
- return fmt.Errorf("samples must be valid JSON")
- }
- if !json.Valid(rep.Metrics) {
- return fmt.Errorf("metrics must be valid JSON")
- }
-
- _, err := q.CreateWorkoutSessionSetRep(ctx, db.CreateWorkoutSessionSetRepParams{
- WorkoutSessionSetID: setRow.ID,
+ if err != nil {
+ return err
+ }
+
+ for _, rep := range dto.Reps {
+ _, err := q.CreateWorkoutSessionSetRep(ctx, db.CreateWorkoutSessionSetRepParams{
+ WorkoutSessionSetID: setRow.ID,
RepNumber: rep.RepNumber,
StartTime: rep.StartTime,
EndTime: rep.EndTime,
diff --git a/internal/core/service/workout/workout_session_set_integration_test.go b/internal/core/service/workout/workout_session_set_integration_test.go
new file mode 100644
index 0000000..4c8f2fc
--- /dev/null
+++ b/internal/core/service/workout/workout_session_set_integration_test.go
@@ -0,0 +1,58 @@
+//go:build integration
+
+package workout
+
+import (
+ "context"
+ "testing"
+
+ "server/internal/testutil/testdb"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateWorkoutSessionSetPersistsSetAndReps(t *testing.T) {
+ harness := testdb.New(t)
+ user := testdb.CreateUser(t, harness.Store, "create_set")
+ workoutType := testdb.CreateWorkoutType(t, harness.Store, "create_set")
+
+ session, err := CreateWorkoutSession(context.Background(), harness.Store, user.ID, CreateWorkoutSessionDTO{
+ WorkoutTypeID: workoutType.ID,
+ })
+ require.NoError(t, err)
+
+ setRow, err := CreateWorkoutSessionSet(context.Background(), harness.Store, user.ID, session.ID, CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ {
+ RepNumber: 1,
+ StartTime: 100,
+ EndTime: 150,
+ Samples: []byte(`[{"frame": 1}]`),
+ Metrics: []byte(`{"peak": 10}`),
+ },
+ {
+ RepNumber: 2,
+ StartTime: 200,
+ EndTime: 260,
+ Samples: []byte(`[{"frame": 2}]`),
+ Metrics: []byte(`{"peak": 12}`),
+ },
+ },
+ })
+ require.NoError(t, err)
+ require.Equal(t, session.ID, setRow.WorkoutSessionID)
+ require.Equal(t, int32(1), setRow.SetNumber)
+
+ sets, err := harness.Store.Queries.GetWorkoutSessionSetsBySessionID(context.Background(), session.ID)
+ require.NoError(t, err)
+ require.Len(t, sets, 1)
+
+ reps, err := harness.Store.Queries.GetWorkoutSessionSetRepsBySessionID(context.Background(), session.ID)
+ require.NoError(t, err)
+ require.Len(t, reps, 2)
+ require.Equal(t, int32(1), reps[0].RepNumber)
+ require.Equal(t, int32(2), reps[1].RepNumber)
+ require.JSONEq(t, `[{"frame": 1}]`, string(reps[0].Samples))
+ require.JSONEq(t, `{"peak": 12}`, string(reps[1].Metrics))
+}
diff --git a/internal/core/service/workout/workout_session_set_service_test.go b/internal/core/service/workout/workout_session_set_service_test.go
new file mode 100644
index 0000000..3a75507
--- /dev/null
+++ b/internal/core/service/workout/workout_session_set_service_test.go
@@ -0,0 +1,115 @@
+//go:build !integration
+
+package workout
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateCreateWorkoutSessionSetDTO(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ dto CreateWorkoutSessionSetDTO
+ expectedErr string
+ }{
+ {
+ name: "invalid set number",
+ dto: CreateWorkoutSessionSetDTO{
+ SetNumber: 0,
+ },
+ expectedErr: "set_number must be greater than 0",
+ },
+ {
+ name: "invalid rep number",
+ dto: CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ validRep(func(rep *CreateWorkoutSessionSetRepDTO) {
+ rep.RepNumber = 0
+ }),
+ },
+ },
+ expectedErr: "rep_number must be greater than 0",
+ },
+ {
+ name: "rep end before start",
+ dto: CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ validRep(func(rep *CreateWorkoutSessionSetRepDTO) {
+ rep.EndTime = rep.StartTime - 1
+ }),
+ },
+ },
+ expectedErr: "rep end_time must be after start_time",
+ },
+ {
+ name: "invalid samples json",
+ dto: CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ validRep(func(rep *CreateWorkoutSessionSetRepDTO) {
+ rep.Samples = json.RawMessage(`{`)
+ }),
+ },
+ },
+ expectedErr: "samples must be valid JSON",
+ },
+ {
+ name: "invalid metrics json",
+ dto: CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ validRep(func(rep *CreateWorkoutSessionSetRepDTO) {
+ rep.Metrics = json.RawMessage(`{`)
+ }),
+ },
+ },
+ expectedErr: "metrics must be valid JSON",
+ },
+ {
+ name: "valid dto",
+ dto: CreateWorkoutSessionSetDTO{
+ SetNumber: 1,
+ Reps: []CreateWorkoutSessionSetRepDTO{
+ validRep(nil),
+ },
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ err := validateCreateWorkoutSessionSetDTO(tc.dto)
+ if tc.expectedErr == "" {
+ require.NoError(t, err)
+ return
+ }
+
+ require.EqualError(t, err, tc.expectedErr)
+ })
+ }
+}
+
+func validRep(mutate func(*CreateWorkoutSessionSetRepDTO)) CreateWorkoutSessionSetRepDTO {
+ rep := CreateWorkoutSessionSetRepDTO{
+ RepNumber: 1,
+ StartTime: 100,
+ EndTime: 200,
+ Samples: json.RawMessage(`[{"angle": 90}]`),
+ Metrics: json.RawMessage(`{"tempo":"controlled"}`),
+ }
+
+ if mutate != nil {
+ mutate(&rep)
+ }
+
+ return rep
+}
diff --git a/internal/data/store.go b/internal/data/store.go
index e93fdcf..cfcd861 100644
--- a/internal/data/store.go
+++ b/internal/data/store.go
@@ -18,7 +18,11 @@ type Store struct {
}
func NewStore(ctx context.Context) (*Store, error) {
- cfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
+ return NewStoreFromURL(ctx, os.Getenv("DATABASE_URL"))
+}
+
+func NewStoreFromURL(ctx context.Context, databaseURL string) (*Store, error) {
+ cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
diff --git a/internal/http/handlers/health.handler_test.go b/internal/http/handlers/health.handler_test.go
new file mode 100644
index 0000000..003bb96
--- /dev/null
+++ b/internal/http/handlers/health.handler_test.go
@@ -0,0 +1,37 @@
+//go:build !integration
+
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "server/internal/testutil"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegisterHealthRoutesReturnsExpectedResponse(t *testing.T) {
+ t.Parallel()
+
+ logger := testutil.NewTestLogger()
+ mux := http.NewServeMux()
+ RegisterHealthRoutes(mux, logger)
+
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ recorder := httptest.NewRecorder()
+
+ mux.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response Response
+ require.NoError(t, json.NewDecoder(recorder.Body).Decode(&response))
+ require.Equal(t, "OK", response.Status)
+ require.Equal(t, "The server is running", response.Description)
+ _, err := time.Parse(time.RFC3339, response.Timestamp)
+ require.NoError(t, err)
+}
diff --git a/internal/http/middleware/logging_test.go b/internal/http/middleware/logging_test.go
new file mode 100644
index 0000000..28c5075
--- /dev/null
+++ b/internal/http/middleware/logging_test.go
@@ -0,0 +1,84 @@
+//go:build !integration
+
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "server/internal/testutil"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestStatusTextCategory(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ status int
+ expected string
+ }{
+ {name: "unknown", status: 0, expected: "unknown"},
+ {name: "informational", status: 102, expected: "informational"},
+ {name: "success", status: 200, expected: "success"},
+ {name: "redirect", status: 302, expected: "redirect"},
+ {name: "client error", status: 404, expected: "client_error"},
+ {name: "server error", status: 503, expected: "server_error"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tc.expected, statusTextCategory(tc.status))
+ })
+ }
+}
+
+func TestLoggingMiddlewareLogsSuccessfulResponses(t *testing.T) {
+ t.Parallel()
+
+ logger := testutil.NewTestLogger()
+ handler := LoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("hello"))
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "/health?check=1", nil)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ entry, ok := logger.LastEntry()
+ require.True(t, ok)
+ require.Equal(t, "info", entry.Level)
+ require.Contains(t, entry.Message, "GET /health?check=1 200")
+ require.Contains(t, entry.Message, "5B")
+ require.Contains(t, entry.Message, "category: success")
+}
+
+func TestLoggingMiddlewareLogsErrorResponses(t *testing.T) {
+ t.Parallel()
+
+ logger := testutil.NewTestLogger()
+ handler := LoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte("oops"))
+ }))
+
+ req := httptest.NewRequest(http.MethodPost, "/api/workouts/sessions", nil)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
+
+ entry, ok := logger.LastEntry()
+ require.True(t, ok)
+ require.Equal(t, "error", entry.Level)
+ require.Contains(t, entry.Message, "POST /api/workouts/sessions 503")
+ require.Contains(t, entry.Message, "4B")
+ require.Contains(t, entry.Message, "category: server_error")
+}
diff --git a/internal/http/middleware/middleware_auth_test.go b/internal/http/middleware/middleware_auth_test.go
new file mode 100644
index 0000000..16b08fa
--- /dev/null
+++ b/internal/http/middleware/middleware_auth_test.go
@@ -0,0 +1,83 @@
+//go:build !integration
+
+package middleware
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "server/internal/testutil"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAuthMiddlewareRejectsMissingAuthorizationHeader(t *testing.T) {
+ t.Parallel()
+
+ logger := testutil.NewTestLogger()
+ handler := AuthMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "/api/users/me", nil)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusUnauthorized, recorder.Code)
+
+ var body map[string]string
+ require.NoError(t, json.NewDecoder(recorder.Body).Decode(&body))
+ require.Equal(t, "missing authorization header", body["error"])
+}
+
+func TestAuthMiddlewareRejectsInvalidBearerFormat(t *testing.T) {
+ t.Parallel()
+
+ logger := testutil.NewTestLogger()
+ handler := AuthMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "/api/users/me", nil)
+ req.Header.Set("Authorization", "Token invalid")
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusUnauthorized, recorder.Code)
+
+ var body map[string]string
+ require.NoError(t, json.NewDecoder(recorder.Body).Decode(&body))
+ require.Equal(t, "invalid authorization header format", body["error"])
+}
+
+func TestAuthMiddlewareAllowsDevelopmentBypass(t *testing.T) {
+ logger := testutil.NewTestLogger()
+
+ t.Setenv("APP_ENV", "development")
+ t.Setenv("DEV_BYPASS_SECRET", "local-secret")
+
+ handler := AuthMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userID, ok := GetUserID(r.Context())
+ require.True(t, ok)
+ require.Equal(t, "user-dev-123", userID)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "/api/users/me", nil)
+ req.Header.Set("X-Dev-Secret", "local-secret")
+ req.Header.Set("X-Dev-User-Id", "user-dev-123")
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+
+ entry, ok := logger.LastEntry()
+ require.True(t, ok)
+ require.Equal(t, "info", entry.Level)
+ require.Contains(t, entry.Message, "Development mode: bypassing auth for user")
+}
diff --git a/internal/http/router_test.go b/internal/http/router_test.go
new file mode 100644
index 0000000..e23856e
--- /dev/null
+++ b/internal/http/router_test.go
@@ -0,0 +1,39 @@
+//go:build !integration
+
+package http
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "server/internal/http/handlers"
+ "server/internal/testutil"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRouterHandlerServesHealthRoute(t *testing.T) {
+ t.Parallel()
+
+ logger := testutil.NewTestLogger()
+ router := New(logger, nil)
+ router.RegisterRoutes()
+
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ recorder := httptest.NewRecorder()
+
+ router.Handler().ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response handlers.Response
+ require.NoError(t, json.NewDecoder(recorder.Body).Decode(&response))
+ require.Equal(t, "OK", response.Status)
+
+ entry, ok := logger.LastEntry()
+ require.True(t, ok)
+ require.Equal(t, "info", entry.Level)
+ require.Contains(t, entry.Message, "GET /health 200")
+}
diff --git a/internal/testutil/testdb/fixtures.go b/internal/testutil/testdb/fixtures.go
new file mode 100644
index 0000000..1567e0b
--- /dev/null
+++ b/internal/testutil/testdb/fixtures.go
@@ -0,0 +1,65 @@
+package testdb
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "server/internal/data"
+ sqlc "server/internal/db"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/stretchr/testify/require"
+)
+
+func CreateUser(t testing.TB, store *data.Store, suffix string) sqlc.User {
+ t.Helper()
+
+ if suffix == "" {
+ suffix = fmt.Sprintf("%d", time.Now().UnixNano())
+ }
+
+ user, err := store.Queries.CreateUser(context.Background(), sqlc.CreateUserParams{
+ ClerkID: fmt.Sprintf("clerk_%s", suffix),
+ FirstName: "Test",
+ LastName: "User",
+ Email: fmt.Sprintf("test_%s@example.com", suffix),
+ ImageUrl: pgtype.Text{},
+ })
+ require.NoError(t, err)
+
+ return user
+}
+
+func CreateWorkoutType(t testing.TB, store *data.Store, suffix string) sqlc.WorkoutType {
+ t.Helper()
+
+ if suffix == "" {
+ suffix = fmt.Sprintf("%d", time.Now().UnixNano())
+ }
+
+ workoutType, err := store.Queries.CreateWorkoutType(context.Background(), sqlc.CreateWorkoutTypeParams{
+ Name: fmt.Sprintf("Workout %s", suffix),
+ Description: pgtype.Text{String: "Integration test workout", Valid: true},
+ Config: []byte(`{"sampleRate":120}`),
+ ImageUrl: pgtype.Text{},
+ })
+ require.NoError(t, err)
+
+ return workoutType
+}
+
+func UpsertCalibration(t testing.TB, store *data.Store, userID string, yaw float64, pitch float64, roll float64) sqlc.UserCalibration {
+ t.Helper()
+
+ calibration, err := store.Queries.UpsertUserCalibration(context.Background(), sqlc.UpsertUserCalibrationParams{
+ UserID: userID,
+ StandingYawAngle: yaw,
+ StandingPitchAngle: pitch,
+ StandingRollAngle: roll,
+ })
+ require.NoError(t, err)
+
+ return calibration
+}
diff --git a/internal/testutil/testdb/testdb.go b/internal/testutil/testdb/testdb.go
new file mode 100644
index 0000000..722a4b9
--- /dev/null
+++ b/internal/testutil/testdb/testdb.go
@@ -0,0 +1,231 @@
+package testdb
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "server/internal/data"
+
+ "github.com/joho/godotenv"
+ _ "github.com/jackc/pgx/v5/stdlib"
+ "github.com/pressly/goose/v3"
+ "github.com/stretchr/testify/require"
+)
+
+var nonSchemaChars = regexp.MustCompile(`[^a-z0-9_]+`)
+
+type Harness struct {
+ Store *data.Store
+ SQLDB *sql.DB
+ Schema string
+ DatabaseURL string
+}
+
+type resolvedTestDatabaseURL struct {
+ URL string
+ Source string
+}
+
+func New(t testing.TB) *Harness {
+ t.Helper()
+
+ resolved := mustResolveTestDatabaseURL(t)
+
+ adminDB := openSQLDB(t, resolved, "integration test admin database")
+ schema := testSchemaName(t.Name())
+ createSchema(t, adminDB, schema)
+
+ isolatedURL := urlWithSearchPath(t, resolved.URL, schema)
+ sqlDB := openSQLDB(t, resolvedTestDatabaseURL{
+ URL: isolatedURL,
+ Source: resolved.Source,
+ }, "integration test schema")
+
+ require.NoError(t, goose.SetDialect("postgres"))
+ require.NoError(t, goose.Up(sqlDB, migrationsDir(t)))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ store, err := data.NewStoreFromURL(ctx, isolatedURL)
+ require.NoError(t, err)
+
+ harness := &Harness{
+ Store: store,
+ SQLDB: sqlDB,
+ Schema: schema,
+ DatabaseURL: isolatedURL,
+ }
+
+ t.Cleanup(func() {
+ if harness.Store != nil {
+ harness.Store.Close()
+ }
+ if harness.SQLDB != nil {
+ require.NoError(t, harness.SQLDB.Close())
+ }
+ _, err := adminDB.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schema))
+ require.NoError(t, err)
+ require.NoError(t, adminDB.Close())
+ })
+
+ return harness
+}
+
+func mustResolveTestDatabaseURL(t testing.TB) resolvedTestDatabaseURL {
+ t.Helper()
+
+ root := repoRoot(t)
+ resolved, err := resolveTestDatabaseURL(root)
+ require.NoError(t, err)
+ return resolved
+}
+
+func resolveTestDatabaseURL(root string) (resolvedTestDatabaseURL, error) {
+ if databaseURL := os.Getenv("TEST_DATABASE_URL"); databaseURL != "" {
+ return resolvedTestDatabaseURL{
+ URL: databaseURL,
+ Source: "process environment",
+ }, nil
+ }
+
+ envPath := filepath.Join(root, ".env")
+ envMap, err := godotenv.Read(envPath)
+ if err != nil {
+ return resolvedTestDatabaseURL{}, fmt.Errorf("TEST_DATABASE_URL is not set in the process environment, and failed to load %s: %w", envPath, err)
+ }
+
+ databaseURL := envMap["TEST_DATABASE_URL"]
+ if databaseURL == "" {
+ return resolvedTestDatabaseURL{}, fmt.Errorf("TEST_DATABASE_URL is not set in the process environment and not found in %s", envPath)
+ }
+
+ return resolvedTestDatabaseURL{
+ URL: databaseURL,
+ Source: envPath,
+ }, nil
+}
+
+func createSchema(t testing.TB, db *sql.DB, schema string) {
+ t.Helper()
+
+ _, err := db.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
+ require.NoError(t, err)
+}
+
+func openSQLDB(t testing.TB, resolved resolvedTestDatabaseURL, purpose string) *sql.DB {
+ t.Helper()
+
+ db, err := sql.Open("pgx", resolved.URL)
+ require.NoErrorf(t, err, "failed to open %s using TEST_DATABASE_URL from %s (%s)", purpose, resolved.Source, databaseTarget(resolved.URL))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ require.NoErrorf(t, db.PingContext(ctx), "failed to connect to %s using TEST_DATABASE_URL from %s (%s)%s", purpose, resolved.Source, databaseTarget(resolved.URL), connectionHint(resolved.URL))
+ return db
+}
+
+func migrationsDir(t testing.TB) string {
+ t.Helper()
+
+ root := repoRoot(t)
+ return filepath.Join(root, "db", "migrations")
+}
+
+func repoRoot(t testing.TB) string {
+ t.Helper()
+
+ dir, err := os.Getwd()
+ require.NoError(t, err)
+
+ for {
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+ return dir
+ }
+
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ t.Fatal("failed to locate repository root from test working directory")
+ }
+
+ dir = parent
+ }
+}
+
+func testSchemaName(testName string) string {
+ base := strings.ToLower(nonSchemaChars.ReplaceAllString(testName, "_"))
+ base = strings.Trim(base, "_")
+ if base == "" {
+ base = "integration"
+ }
+ if len(base) > 24 {
+ base = base[:24]
+ }
+
+ return fmt.Sprintf("it_%s_%d", base, time.Now().UnixNano())
+}
+
+func urlWithSearchPath(t testing.TB, databaseURL string, schema string) string {
+ t.Helper()
+
+ parsed, err := url.Parse(databaseURL)
+ require.NoError(t, err)
+
+ query := parsed.Query()
+ query.Set("search_path", schema)
+ parsed.RawQuery = query.Encode()
+ return parsed.String()
+}
+
+func databaseTarget(databaseURL string) string {
+ parsed, err := url.Parse(databaseURL)
+ if err != nil {
+ return "unparseable TEST_DATABASE_URL"
+ }
+
+ parts := make([]string, 0, 4)
+ if parsed.User != nil && parsed.User.Username() != "" {
+ parts = append(parts, "user="+parsed.User.Username())
+ }
+ if host := parsed.Hostname(); host != "" {
+ parts = append(parts, "host="+host)
+ }
+ if port := parsed.Port(); port != "" {
+ parts = append(parts, "port="+port)
+ }
+ if dbName := strings.TrimPrefix(parsed.Path, "/"); dbName != "" {
+ parts = append(parts, "db="+dbName)
+ }
+
+ if len(parts) == 0 {
+ return "TEST_DATABASE_URL"
+ }
+
+ return strings.Join(parts, ", ")
+}
+
+func connectionHint(databaseURL string) string {
+ parsed, err := url.Parse(databaseURL)
+ if err != nil {
+ return ""
+ }
+
+ switch parsed.Hostname() {
+ case "db":
+ return " Hint: hostname `db` only resolves inside Docker Compose. For local integration tests, set TEST_DATABASE_URL to use 127.0.0.1 and the configured TEST_POSTGRES_PORT."
+ case "127.0.0.1", "localhost", "::1":
+ return " If this is a local host-side run, confirm the Postgres container is up and listening on the configured TEST_POSTGRES_PORT."
+ default:
+ return ""
+ }
+}
+
diff --git a/internal/testutil/testdb/testdb_test.go b/internal/testutil/testdb/testdb_test.go
new file mode 100644
index 0000000..4e8da5f
--- /dev/null
+++ b/internal/testutil/testdb/testdb_test.go
@@ -0,0 +1,85 @@
+//go:build !integration
+
+package testdb
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveTestDatabaseURLPrefersProcessEnvironment(t *testing.T) {
+ t.Setenv("TEST_DATABASE_URL", "postgres://from-process")
+
+ root := t.TempDir()
+ envPath := filepath.Join(root, ".env")
+ require.NoError(t, os.WriteFile(envPath, []byte("TEST_DATABASE_URL=postgres://from-dotenv\n"), 0o644))
+
+ resolved, err := resolveTestDatabaseURL(root)
+ require.NoError(t, err)
+ require.Equal(t, "postgres://from-process", resolved.URL)
+ require.Equal(t, "process environment", resolved.Source)
+}
+
+func TestResolveTestDatabaseURLLoadsRepoDotEnv(t *testing.T) {
+ require.NoError(t, os.Unsetenv("TEST_DATABASE_URL"))
+ t.Cleanup(func() {
+ _ = os.Unsetenv("TEST_DATABASE_URL")
+ })
+
+ root := t.TempDir()
+ envPath := filepath.Join(root, ".env")
+ require.NoError(t, os.WriteFile(envPath, []byte("TEST_DATABASE_URL=postgres://from-dotenv\n"), 0o644))
+
+ resolved, err := resolveTestDatabaseURL(root)
+ require.NoError(t, err)
+ require.Equal(t, "postgres://from-dotenv", resolved.URL)
+ require.Equal(t, envPath, resolved.Source)
+}
+
+func TestResolveTestDatabaseURLFailsWhenMissingEverywhere(t *testing.T) {
+ require.NoError(t, os.Unsetenv("TEST_DATABASE_URL"))
+ t.Cleanup(func() {
+ _ = os.Unsetenv("TEST_DATABASE_URL")
+ })
+
+ root := t.TempDir()
+ envPath := filepath.Join(root, ".env")
+ require.NoError(t, os.WriteFile(envPath, []byte("DATABASE_URL=postgres://app-db\n"), 0o644))
+
+ resolved, err := resolveTestDatabaseURL(root)
+ require.Error(t, err)
+ require.Empty(t, resolved.URL)
+ require.Contains(t, err.Error(), "TEST_DATABASE_URL is not set in the process environment and not found in")
+ require.Contains(t, err.Error(), envPath)
+}
+
+func TestResolveTestDatabaseURLFailsWhenRepoDotEnvCannotBeLoaded(t *testing.T) {
+ require.NoError(t, os.Unsetenv("TEST_DATABASE_URL"))
+ t.Cleanup(func() {
+ _ = os.Unsetenv("TEST_DATABASE_URL")
+ })
+
+ root := t.TempDir()
+
+ resolved, err := resolveTestDatabaseURL(root)
+ require.Error(t, err)
+ require.Empty(t, resolved.URL)
+ require.Contains(t, err.Error(), "TEST_DATABASE_URL is not set in the process environment, and failed to load")
+ require.Contains(t, err.Error(), filepath.Join(root, ".env"))
+}
+
+func TestConnectionHintForComposeHostname(t *testing.T) {
+ hint := connectionHint("postgres://capstone:capstone@db:5432/capstone?sslmode=disable")
+ require.Contains(t, hint, "hostname `db` only resolves inside Docker Compose")
+ require.Contains(t, hint, "TEST_POSTGRES_PORT")
+}
+
+func TestConnectionHintForHostSideURL(t *testing.T) {
+ hint := connectionHint("postgres://capstone:capstone@127.0.0.1:5432/capstone?sslmode=disable")
+ require.Contains(t, hint, "local host-side run")
+ require.Contains(t, hint, "TEST_POSTGRES_PORT")
+}
+
diff --git a/internal/testutil/testlogger.go b/internal/testutil/testlogger.go
new file mode 100644
index 0000000..5ef9565
--- /dev/null
+++ b/internal/testutil/testlogger.go
@@ -0,0 +1,62 @@
+package testutil
+
+import (
+ "fmt"
+ "sync"
+)
+
+type LogEntry struct {
+ Level string
+ Message string
+}
+
+type TestLogger struct {
+ mu sync.Mutex
+ entries []LogEntry
+}
+
+func NewTestLogger() *TestLogger {
+ return &TestLogger{}
+}
+
+func (l *TestLogger) Info(format string, args ...interface{}) {
+ l.append("info", format, args...)
+}
+
+func (l *TestLogger) Error(format string, args ...interface{}) {
+ l.append("error", format, args...)
+}
+
+func (l *TestLogger) Debug(format string, args ...interface{}) {
+ l.append("debug", format, args...)
+}
+
+func (l *TestLogger) Entries() []LogEntry {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ entries := make([]LogEntry, len(l.entries))
+ copy(entries, l.entries)
+ return entries
+}
+
+func (l *TestLogger) LastEntry() (LogEntry, bool) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ if len(l.entries) == 0 {
+ return LogEntry{}, false
+ }
+
+ return l.entries[len(l.entries)-1], true
+}
+
+func (l *TestLogger) append(level string, format string, args ...interface{}) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.entries = append(l.entries, LogEntry{
+ Level: level,
+ Message: fmt.Sprintf(format, args...),
+ })
+}