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...), + }) +}