diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 48609ff..b409c16 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,97 +5,72 @@ Run repository-wide commands from the root: ```bash -make dev # build and run PostgreSQL, API, and frontend -make db-up # start only PostgreSQL -make migrate # apply Goose migrations -make migrate-down # roll back one migration -make test # backend and frontend unit/component tests -make test-integration # PostgreSQL-backed repository tests; database must be running +make dev # run PostgreSQL, API, and frontend with development fixtures +make test # complete suite, including disposable PostgreSQL integration tests +make test-integration # Go tests; starts its own PostgreSQL Testcontainer +make test-env # isolated seeded UI stack at http://localhost:15173 +make test-env-down # remove the test stack and its PostgreSQL volume make lint # go vet and ESLint make build # compile the API and production frontend ``` -Run focused tests from the owning application: +Focused tests: ```bash cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$' +cd backend && go test -tags=integration ./internal/integration -run '^TestCompleteInterviewWorkflow$' cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" ``` -The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Frontend -formatting can be checked with `cd frontend && npm run format:check`; backend files -are formatted with `gofmt`. +The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use +`cd frontend && npm run format:check` for frontend formatting and `gofmt` for Go. ## Architecture -The repository has two applications: +- `backend/cmd/api` wires configuration, PostgreSQL, security, services, handlers, + and the Chi router. Requests flow through `internal/web/{router,handler,model,mapper}` + to `internal/service/{model,services}` and then + `internal/database/{repository,mapper,entity}`. Never expose GORM entities from + handlers. +- `backend/cmd/seed` applies embedded environment-aware fixtures. +- `frontend/src/App.tsx` owns routing/layout/screens, `src/api.ts` is the typed HTTP + boundary, and `src/types.ts` contains client domain types. Vite proxies `/api` to + the Go service in development. -- `backend/` is a Go HTTP API. `cmd/api` wires configuration, PostgreSQL, security, - services, handlers, and routing. Requests flow through - `internal/web/{router,handler,model,mapper}` to - `internal/service/{model,services}` and then - `internal/database/{repository,mapper,entity}`. Do not bypass these mapping - boundaries by exposing GORM entities from handlers. -- `frontend/` is a React SPA. `src/App.tsx` owns routing/layout/screens, - `src/api.ts` is the typed HTTP boundary, and `src/types.ts` contains client domain - types. Vite proxies `/api` to the Go service in development. +Goose files in `backend/migrations` are schema-only and run in every environment. +`internal/database/seed/common.sql` contains shared reference data; +`development.sql` and `test.sql` contain idempotent environment-specific fixtures. +Every entity change requires a migration plus corresponding fixture and integration +test updates. Never put demo users or test records in schema migrations. -Goose SQL files in `backend/migrations/` are the database schema source of truth. -The initial migration also supplies representative jobs, questions, participants, -traits, and the local interviewer account. +Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are +protected except `/api/health`, `/api/auth/login`, and opaque-token participant +interview routes. Never expose password hashes, CV/photo bytes, or interviewer +reference answers in public JSON. -Authentication is custom bearer-token authentication: passwords are bcrypt hashes, -`POST /api/auth/login` issues an HMAC-signed JWT, and all domain routes require that -token. Never return password hashes or participant photo/CV bytes in JSON; those -files have dedicated endpoints. +Questions are permanent reusable records linked to jobs through +`job_posting_questions`; detach links rather than deleting questions. Deprecated +questions remain searchable but cannot be newly attached. Question values are +`open`, `multiple_choice`, `radio`, and `code_review`. Choice types require at least +two options. Open/code-review types require `referenceAnswer`; code review also +requires `codeSnippet`. -CV ingestion stores the original bytes and extracts text from plain text, PDF, or -DOCX input. Trait extraction only considers normalized traits already present in -the database. Candidate matching compares a participant's extracted traits with a -job's labels, required skills, and additional skills; include matches only at the -`service.CandidateThreshold` of 60% or higher. - -Questions are permanent, reusable library records and jobs own many-to-many links -to them. Detaching removes only the link. The lifecycle is -`draft -> published -> deprecated`; publishing requires at least one question, and -only published jobs can create invitations. Once an invitation exists, its question -set cannot be changed, and its duration is snapshotted so later job edits do not -alter an active interview. - -Invitation URLs expose an opaque UUID token as `/participant/:invitationId` and use -public `/api/interviews/{token}` API routes; they do not use interviewer JWT -authentication. An invitation progresses from -`pending` to `accepted` to `completed`. Answers may only be saved while accepted -and before the snapshotted timer expires. The participant UI must gate questions -behind explicit acceptance and provide First, Previous, Next, Last, and Submit -controls. Interviewer review uses the protected -`GET /api/jobs/{id}/invitations/{invitationId}` endpoint, which intentionally -includes reference answers; the public interview endpoint does not. +Jobs move through `draft`, `published`, and `deprecated`. Publishing requires a +question, only published jobs can create invitations, and candidate matching uses +labels plus both skill groups with a 60% threshold. Invitation URLs use +`/participant/:invitationId`, where the identifier is an opaque token. Participant +questions are acceptance-gated and answers are saved before final submission; +interviewer review uses +`GET /api/jobs/{id}/invitations/{invitationId}`. ## Repository conventions -- Every Go interface name starts with `I` (`IStore`, `IJobService`, - `IParticipantService`, `ITokenManager`). -- Keep transport, service, and persistence structs separate. Conversion belongs in - the adjacent `mapper` package, not in handlers or GORM entities. -- Question types are the exact API values `open`, `multiple_choice`, `radio`, and - `code_review`. Multiple-choice and radio questions require at least two options. -- Never delete question records through job operations. Use - `job_posting_questions` to attach/detach, and use the `deprecated` flag to retire - library questions. Full question edits use `PUT /api/questions/{id}`; deprecation - remains a separate `PATCH`. -- The question create/edit form builds multiple-choice and radio options one at a - time. Keep option editing state separate from the saved options array and require - at least two options for those types. Open and code-review questions instead - require a `referenceAnswer` textarea. Never include that interviewer-only answer - in public interview responses. -- Code-review questions also require `codeSnippet`. It is safe to include in the - participant payload and is rendered as a line-numbered pull-request review panel; - the participant's review comment remains the ordinary persisted answer. -- Traits are de-duplicated case-insensitively through their normalized database - value. Labels and both skill groups all reuse the same `traits` table. -- Participant creation is multipart form data using `firstName`, `lastName`, - `birthday`, `email`, `contactInfo`, `photo`, and `cv`. -- Return explicit errors. Handlers convert expected validation/not-found/authentication - failures to HTTP responses and log unexpected failures rather than silently - falling back to demo data. +- Prefix every Go interface name with `I`. +- Keep web, service, and persistence models separate and convert them in adjacent + `mapper` packages. +- Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, + `email`, `contactInfo`, `photo`, and `cv`. +- Return explicit errors; map expected failures to HTTP status codes and log + unexpected failures instead of silently falling back. +- Commit every repository change with a meaningful conventional prefix such as + `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d450835 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + - uses: actions/setup-node@v5 + with: + node-version: 26 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: make test + - run: make lint + - run: make build diff --git a/Makefile b/Makefile index c642c01..a5db44d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=dis JWT_SECRET ?= local-development-secret-change-me-12345 GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0 -.PHONY: dev db-up db-down migrate migrate-down backend frontend test test-backend test-frontend test-integration lint build +.PHONY: dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-env test-env-down lint build dev: docker compose up --build @@ -21,13 +21,19 @@ migrate: migrate-down: $(GOOSE) -dir migrations postgres "$(DATABASE_URL)" down +seed: + cd backend && DATABASE_URL="$(DATABASE_URL)" APP_ENV="$${APP_ENV:-development}" go run ./cmd/seed + backend: cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api frontend: cd frontend && npm run dev -test: test-backend test-frontend +frontend-install: + cd frontend && npm ci + +test: frontend-install test-integration test-frontend test-backend: cd backend && go test ./... @@ -36,7 +42,14 @@ test-frontend: cd frontend && npm test test-integration: - cd backend && TEST_DATABASE_URL="$(DATABASE_URL)" go test -tags=integration ./internal/database/repository + cd backend && go test -tags=integration ./... + +test-env: + COMPOSE_PROJECT_NAME=go-guess-test APP_ENV=test DB_PORT=15432 API_PORT=18080 \ + WEB_PORT=15173 FRONTEND_URL=http://localhost:15173 docker compose up -d --build + +test-env-down: + COMPOSE_PROJECT_NAME=go-guess-test docker compose down -v --remove-orphans lint: cd backend && go vet ./... diff --git a/README.md b/README.md index 62958d3..1a3ab43 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ To run the applications outside containers: ```bash make db-up make migrate +make seed make backend make frontend ``` @@ -39,8 +40,8 @@ outside local development. ## Quality commands ```bash -make test -make test-integration +make test # full suite, including disposable PostgreSQL integration tests +make test-integration # Go integration suite with its own PostgreSQL container make lint make build ``` @@ -57,6 +58,19 @@ Run one frontend test: cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" ``` +Docker must be available for `make test`. The integration suite starts PostgreSQL +18 in a disposable Testcontainer, applies every Goose migration, loads deterministic +test fixtures, exercises login and the complete interview workflow, and removes the +container afterward. + +Start an isolated application stack populated with test data for manual UI testing: + +```bash +make test-env +# Open http://localhost:15173 and use integration@example.com / integration-password +make test-env-down +``` + ## Architecture Requests enter the Chi router and web handlers under `backend/internal/web`. @@ -72,9 +86,12 @@ submission without requiring an interviewer login. Interviewers can review every saved answer from the job's invitation list. Vite proxies `/api` to the Go service during local development. -Goose migrations in `backend/migrations` are the schema source of truth and include -representative local data. Uploaded CVs are stored as bytes; plain text, PDF, and -DOCX content is inspected for normalized traits already defined by job postings. +Goose migrations in `backend/migrations` are schema-only and run in every +environment. Embedded SQL under `backend/internal/database/seed` separates shared +reference traits from idempotent development and test fixtures. Docker Compose +loads development fixtures by default; production loads only shared data. Uploaded +CVs are stored as bytes; plain text, PDF, and DOCX content is inspected for +normalized traits already defined by job postings. Jobs move through `draft`, `published`, and `deprecated` states. Questions are editable, permanent library records: jobs attach and detach links rather than diff --git a/backend/Dockerfile b/backend/Dockerfile index 5aa5e89..3cd0634 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,8 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed FROM golang:1.27.1-alpine AS tools RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 @@ -12,8 +13,9 @@ FROM alpine:3.22 RUN addgroup -S app && adduser -S app -G app WORKDIR /app COPY --from=build /out/api /usr/local/bin/api +COPY --from=build /out/seed /usr/local/bin/seed COPY --from=tools /out/goose /usr/local/bin/goose COPY migrations ./migrations USER app EXPOSE 8080 -CMD ["sh", "-c", "goose -dir migrations postgres \"$DATABASE_URL\" up && exec api"] +CMD ["sh", "-c", "goose -dir migrations postgres \"$DATABASE_URL\" up && seed && exec api"] diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..bb873f2 --- /dev/null +++ b/backend/cmd/seed/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log/slog" + "os" + + "github.com/danyel/go-guess/backend/internal/database" + "github.com/danyel/go-guess/backend/internal/database/seed" +) + +func main() { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + slog.Error("DATABASE_URL is required") + os.Exit(1) + } + db, err := database.Open(databaseURL) + if err != nil { + slog.Error("connect database", "error", err) + os.Exit(1) + } + sqlDB, err := db.DB() + if err != nil { + slog.Error("get database connection", "error", err) + os.Exit(1) + } + defer sqlDB.Close() + if err := seed.Apply(context.Background(), sqlDB, os.Getenv("APP_ENV")); err != nil { + slog.Error("seed database", "error", err) + os.Exit(1) + } +} diff --git a/backend/go.mod b/backend/go.mod index 177c5c7..2ecd304 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,20 +6,73 @@ require ( github.com/go-chi/chi/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.7.5 + github.com/jackc/pgx/v5 v5.9.2 github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 - golang.org/x/crypto v0.43.0 + golang.org/x/crypto v0.54.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.0 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.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/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/stretchr/testify v1.11.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.30.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/pressly/goose/v3 v3.26.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/testcontainers/testcontainers-go v0.44.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index ebef8a7..f5f8a71 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,8 +1,46 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -13,27 +51,105 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU= github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +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/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/internal/database/seed/common.sql b/backend/internal/database/seed/common.sql new file mode 100644 index 0000000..8e0622f --- /dev/null +++ b/backend/internal/database/seed/common.sql @@ -0,0 +1,9 @@ +INSERT INTO traits (name, normalized) VALUES + ('Go', 'go'), + ('PostgreSQL', 'postgresql'), + ('Docker', 'docker'), + ('React', 'react'), + ('TypeScript', 'typescript'), + ('Kubernetes', 'kubernetes'), + ('Backend', 'backend') +ON CONFLICT (normalized) DO NOTHING; diff --git a/backend/internal/database/seed/development.sql b/backend/internal/database/seed/development.sql new file mode 100644 index 0000000..a1a983e --- /dev/null +++ b/backend/internal/database/seed/development.sql @@ -0,0 +1,87 @@ +INSERT INTO users (email, password_hash, role) +VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer') +ON CONFLICT (email) DO NOTHING; + +INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes) +SELECT 'Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.', + 'Senior', 'Backend Engineer', 'published', 60 +WHERE NOT EXISTS (SELECT 1 FROM job_postings WHERE title = 'Senior Go Backend Engineer'); + +INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes) +SELECT 'Full-stack Product Engineer', 'Deliver accessible operational tools from database to browser.', + 'Medior', 'Full-stack Engineer', 'published', 45 +WHERE NOT EXISTS (SELECT 1 FROM job_postings WHERE title = 'Full-stack Product Engineer'); + +INSERT INTO questions (text, type, reference_answer) +SELECT 'How do you design graceful shutdown for an HTTP service?', 'open', + 'Stop accepting work, cancel dependent operations, drain active requests, and close resources within a deadline.' +WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'How do you design graceful shutdown for an HTTP service?'); + +INSERT INTO questions (text, type) +SELECT 'Which construct protects shared data across goroutines?', 'radio' +WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Which construct protects shared data across goroutines?'); + +INSERT INTO questions (text, type, reference_answer, code_snippet) +SELECT 'Review this component and identify unnecessary renders.', 'code_review', + 'Use a stable key and avoid recreating expensive derived values when the candidates input has not changed.', + E'function CandidateList({ candidates }) {\n return candidates.map((candidate, index) => (\n \n ));\n}' +WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Review this component and identify unnecessary renders.'); + +INSERT INTO question_options (question_id, text) +SELECT q.id, option.text +FROM questions q +CROSS JOIN (VALUES ('sync.Mutex'), ('context.Context'), ('http.Client')) AS option(text) +WHERE q.text = 'Which construct protects shared data across goroutines?' + AND NOT EXISTS (SELECT 1 FROM question_options existing WHERE existing.question_id = q.id); + +INSERT INTO job_posting_questions (job_posting_id, question_id) +SELECT j.id, q.id +FROM job_postings j +JOIN questions q ON q.text IN ( + 'How do you design graceful shutdown for an HTTP service?', + 'Which construct protects shared data across goroutines?' +) +WHERE j.title = 'Senior Go Backend Engineer' +ON CONFLICT DO NOTHING; + +INSERT INTO job_posting_questions (job_posting_id, question_id) +SELECT j.id, q.id +FROM job_postings j +JOIN questions q ON q.text = 'Review this component and identify unnecessary renders.' +WHERE j.title = 'Full-stack Product Engineer' +ON CONFLICT DO NOTHING; + +INSERT INTO job_posting_labels (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('backend', 'go') +WHERE j.title = 'Senior Go Backend Engineer' ON CONFLICT DO NOTHING; +INSERT INTO job_required_skills (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('go', 'postgresql', 'docker') +WHERE j.title = 'Senior Go Backend Engineer' ON CONFLICT DO NOTHING; +INSERT INTO job_additional_skills (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized = 'kubernetes' +WHERE j.title = 'Senior Go Backend Engineer' ON CONFLICT DO NOTHING; + +INSERT INTO job_posting_labels (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('react', 'typescript') +WHERE j.title = 'Full-stack Product Engineer' ON CONFLICT DO NOTHING; +INSERT INTO job_required_skills (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('go', 'react', 'typescript') +WHERE j.title = 'Full-stack Product Engineer' ON CONFLICT DO NOTHING; +INSERT INTO job_additional_skills (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized = 'docker' +WHERE j.title = 'Full-stack Product Engineer' ON CONFLICT DO NOTHING; + +INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename) +VALUES + ('Ada', 'Lovelace', '1990-12-10', 'ada@example.com', '+32 470 00 00 01', + convert_to('Senior backend engineer with Go, PostgreSQL and Docker experience.', 'UTF8'), 'ada-cv.txt'), + ('Grace', 'Hopper', '1988-12-09', 'grace@example.com', '+32 470 00 00 02', + convert_to('Full-stack developer working with Go, React and TypeScript.', 'UTF8'), 'grace-cv.txt') +ON CONFLICT (email) DO NOTHING; + +INSERT INTO participant_traits (participant_id, trait_id) +SELECT p.id, t.id FROM participants p JOIN traits t ON t.normalized IN ('go', 'postgresql', 'docker') +WHERE p.email = 'ada@example.com' ON CONFLICT DO NOTHING; +INSERT INTO participant_traits (participant_id, trait_id) +SELECT p.id, t.id FROM participants p JOIN traits t ON t.normalized IN ('go', 'react', 'typescript') +WHERE p.email = 'grace@example.com' ON CONFLICT DO NOTHING; diff --git a/backend/internal/database/seed/seed.go b/backend/internal/database/seed/seed.go new file mode 100644 index 0000000..0b50150 --- /dev/null +++ b/backend/internal/database/seed/seed.go @@ -0,0 +1,38 @@ +package seed + +import ( + "context" + "database/sql" + "embed" + "fmt" +) + +//go:embed *.sql +var files embed.FS + +func Apply(ctx context.Context, db *sql.DB, environment string) error { + if _, err := apply(ctx, db, "common.sql"); err != nil { + return err + } + switch environment { + case "", "production": + return nil + case "development", "test": + _, err := apply(ctx, db, environment+".sql") + return err + default: + return fmt.Errorf("unsupported APP_ENV %q", environment) + } +} + +func apply(ctx context.Context, db *sql.DB, filename string) (sql.Result, error) { + content, err := files.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read seed %s: %w", filename, err) + } + result, err := db.ExecContext(ctx, string(content)) + if err != nil { + return nil, fmt.Errorf("apply seed %s: %w", filename, err) + } + return result, nil +} diff --git a/backend/internal/database/seed/test.sql b/backend/internal/database/seed/test.sql new file mode 100644 index 0000000..a68fe36 --- /dev/null +++ b/backend/internal/database/seed/test.sql @@ -0,0 +1,40 @@ +INSERT INTO users (email, password_hash, role) +VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'interviewer') +ON CONFLICT (email) DO NOTHING; + +INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes) +SELECT 'Integration Backend Engineer', 'Deterministic job fixture for API and UI integration tests.', + 'Senior', 'Backend Engineer', 'published', 30 +WHERE NOT EXISTS (SELECT 1 FROM job_postings WHERE title = 'Integration Backend Engineer'); + +INSERT INTO questions (text, type, reference_answer) +SELECT 'Explain graceful shutdown.', 'open', + 'Stop new work, cancel dependencies, drain requests, and close resources.' +WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Explain graceful shutdown.'); + +INSERT INTO questions (text, type, reference_answer, code_snippet) +SELECT 'Review the error handling.', 'code_review', 'Return or log the error instead of discarding it.', + E'func save() error {\n persist()\n return nil\n}' +WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Review the error handling.'); + +INSERT INTO job_posting_questions (job_posting_id, question_id) +SELECT j.id, q.id FROM job_postings j +JOIN questions q ON q.text IN ('Explain graceful shutdown.', 'Review the error handling.') +WHERE j.title = 'Integration Backend Engineer' +ON CONFLICT DO NOTHING; + +INSERT INTO job_posting_labels (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized = 'backend' +WHERE j.title = 'Integration Backend Engineer' ON CONFLICT DO NOTHING; +INSERT INTO job_required_skills (job_posting_id, trait_id) +SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('go', 'postgresql') +WHERE j.title = 'Integration Backend Engineer' ON CONFLICT DO NOTHING; + +INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename) +VALUES ('Integration', 'Candidate', '1992-04-15', 'candidate.integration@example.com', + '+32 470 00 00 99', convert_to('Backend engineer using Go and PostgreSQL.', 'UTF8'), 'integration-cv.txt') +ON CONFLICT (email) DO NOTHING; + +INSERT INTO participant_traits (participant_id, trait_id) +SELECT p.id, t.id FROM participants p JOIN traits t ON t.normalized IN ('backend', 'go', 'postgresql') +WHERE p.email = 'candidate.integration@example.com' ON CONFLICT DO NOTHING; diff --git a/backend/internal/integration/api_test.go b/backend/internal/integration/api_test.go new file mode 100644 index 0000000..5347a4e --- /dev/null +++ b/backend/internal/integration/api_test.go @@ -0,0 +1,272 @@ +//go:build integration + +package integration + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/pressly/goose/v3" + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "github.com/danyel/go-guess/backend/internal/database/repository" + "github.com/danyel/go-guess/backend/internal/database/seed" + "github.com/danyel/go-guess/backend/internal/security" + "github.com/danyel/go-guess/backend/internal/service" + "github.com/danyel/go-guess/backend/internal/web/handler" + webmodel "github.com/danyel/go-guess/backend/internal/web/model" + "github.com/danyel/go-guess/backend/internal/web/router" +) + +var testServer *httptest.Server + +func TestMain(m *testing.M) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + container, err := tcpostgres.Run( + ctx, + "postgres:18-alpine", + tcpostgres.WithDatabase("go_guess_test"), + tcpostgres.WithUsername("go_guess"), + tcpostgres.WithPassword("go_guess"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + fmt.Fprintln(os.Stderr, "start PostgreSQL test container:", err) + os.Exit(1) + } + + databaseURL, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + fmt.Fprintln(os.Stderr, "get PostgreSQL connection string:", err) + os.Exit(1) + } + sqlDB, err := sql.Open("pgx", databaseURL) + if err != nil { + fmt.Fprintln(os.Stderr, "open PostgreSQL:", err) + _ = container.Terminate(context.Background()) + os.Exit(1) + } + + if err := goose.SetDialect("postgres"); err != nil { + fmt.Fprintln(os.Stderr, "set Goose dialect:", err) + os.Exit(1) + } + _, filename, _, _ := runtime.Caller(0) + migrations := filepath.Join(filepath.Dir(filename), "..", "..", "migrations") + if err := goose.Up(sqlDB, migrations); err != nil { + fmt.Fprintln(os.Stderr, "apply migrations:", err) + os.Exit(1) + } + if err := seed.Apply(ctx, sqlDB, "test"); err != nil { + fmt.Fprintln(os.Stderr, "apply test seed:", err) + os.Exit(1) + } + if err := seed.Apply(ctx, sqlDB, "test"); err != nil { + fmt.Fprintln(os.Stderr, "reapply idempotent test seed:", err) + os.Exit(1) + } + + gormDB, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + fmt.Fprintln(os.Stderr, "open GORM connection:", err) + os.Exit(1) + } + store := repository.New(gormDB) + tokens := security.NewTokenManager("integration-test-secret-at-least-32-bytes", time.Hour) + invitations := service.NewInvitationService(store, "http://example.test") + testServer = httptest.NewServer(router.New( + handler.New( + service.NewAuthService(store, tokens), + service.NewJobService(store), + service.NewQuestionService(store), + service.NewParticipantService(store), + invitations, + 10, + "http://example.test", + ), + tokens, + "http://example.test", + )) + + code := m.Run() + testServer.Close() + _ = sqlDB.Close() + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + _ = container.Terminate(cleanupCtx) + cleanupCancel() + os.Exit(code) +} + +func TestCompleteInterviewWorkflow(t *testing.T) { + var login webmodel.LoginResponse + requestJSON(t, http.MethodPost, "/api/auth/login", "", map[string]any{ + "email": "integration@example.com", "password": "integration-password", + }, http.StatusOK, &login) + if login.Token == "" { + t.Fatal("login did not return a token") + } + + var participants []webmodel.ParticipantResponse + requestJSON(t, http.MethodGet, "/api/participants", login.Token, nil, http.StatusOK, &participants) + var participantID uint + for _, participant := range participants { + if participant.Email == "candidate.integration@example.com" { + participantID = participant.ID + } + } + if participantID == 0 { + t.Fatal("test participant was not seeded") + } + + var question webmodel.QuestionResponse + requestJSON(t, http.MethodPost, "/api/questions", login.Token, map[string]any{ + "text": "Review this error handling.", "type": "code_review", + "codeSnippet": "func run() error {\n execute()\n return nil\n}", + "referenceAnswer": "Return the execute error.", "options": []string{}, + }, http.StatusCreated, &question) + requestJSON(t, http.MethodPut, fmt.Sprintf("/api/questions/%d", question.ID), login.Token, map[string]any{ + "text": "Review this ignored error.", "type": "code_review", + "codeSnippet": "func run() error {\n execute()\n return nil\n}", + "referenceAnswer": "Propagate the execute error.", "options": []string{}, + }, http.StatusOK, &question) + + var jobs []webmodel.JobResponse + requestJSON(t, http.MethodGet, "/api/jobs", login.Token, nil, http.StatusOK, &jobs) + var jobID uint + for _, job := range jobs { + if job.Title == "Integration Backend Engineer" { + jobID = job.ID + } + } + if jobID == 0 { + t.Fatal("test job was not seeded") + } + + requestJSON(t, http.MethodPost, + fmt.Sprintf("/api/jobs/%d/questions/%d", jobID, question.ID), + login.Token, nil, http.StatusNoContent, nil) + + var matches []webmodel.CandidateResponse + requestJSON(t, http.MethodGet, fmt.Sprintf("/api/jobs/%d/candidates", jobID), + login.Token, nil, http.StatusOK, &matches) + if len(matches) == 0 || matches[0].Participant.ID != participantID { + t.Fatalf("seeded participant was not a candidate: %#v", matches) + } + + var invitation webmodel.InvitationResponse + requestJSON(t, http.MethodPost, fmt.Sprintf("/api/jobs/%d/invitations", jobID), + login.Token, map[string]any{"participantId": participantID}, http.StatusCreated, &invitation) + if invitation.Token == "" || invitation.ParticipantURL != "http://example.test/participant/"+invitation.Token { + t.Fatalf("unexpected invitation: %#v", invitation) + } + + var welcome webmodel.InterviewResponse + publicBody := requestJSON(t, http.MethodGet, "/api/interviews/"+invitation.Token, + "", nil, http.StatusOK, &welcome) + if bytes.Contains(publicBody, []byte("referenceAnswer")) || welcome.Invitation.Status != "pending" { + t.Fatalf("invalid public welcome response: %s", publicBody) + } + + var started webmodel.InterviewResponse + requestJSON(t, http.MethodPost, "/api/interviews/"+invitation.Token+"/accept", + "", nil, http.StatusOK, &started) + if started.Invitation.Status != "accepted" || started.Invitation.AcceptedAt == nil { + t.Fatalf("interview was not accepted: %#v", started.Invitation) + } + + answer := "The execute error must be returned to the caller." + requestJSON(t, http.MethodPut, + fmt.Sprintf("/api/interviews/%s/answers/%d", invitation.Token, question.ID), + "", map[string]any{"answer": answer}, http.StatusNoContent, nil) + requestJSON(t, http.MethodPost, "/api/interviews/"+invitation.Token+"/finish", + "", nil, http.StatusOK, &started) + if started.Invitation.Status != "completed" { + t.Fatalf("interview was not completed: %#v", started.Invitation) + } + + var review webmodel.InvitationReviewResponse + requestJSON(t, http.MethodGet, + fmt.Sprintf("/api/jobs/%d/invitations/%d", jobID, invitation.ID), + login.Token, nil, http.StatusOK, &review) + if review.Answers[question.ID] != answer { + t.Fatalf("saved answer missing from review: %#v", review.Answers) + } + found := false + for _, item := range review.Job.Questions { + if item.ID == question.ID { + found = item.ReferenceAnswer == "Propagate the execute error." && item.CodeSnippet != "" + } + } + if !found { + t.Fatalf("review did not include interviewer question context: %#v", review.Job.Questions) + } +} + +func requestJSON( + t *testing.T, + method string, + path string, + token string, + body any, + wantStatus int, + target any, +) []byte { + t.Helper() + var requestBody io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + requestBody = bytes.NewReader(encoded) + } + request, err := http.NewRequest(method, testServer.URL+path, requestBody) + if err != nil { + t.Fatal(err) + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + content, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != wantStatus { + t.Fatalf("%s %s: expected %d, got %d: %s", method, path, wantStatus, response.StatusCode, content) + } + if target != nil { + if err := json.Unmarshal(content, target); err != nil { + t.Fatalf("decode %s %s: %v: %s", method, path, err, content) + } + } + return content +} diff --git a/backend/migrations/00001_initial.sql b/backend/migrations/00001_initial.sql index 568b406..473e2c7 100644 --- a/backend/migrations/00001_initial.sql +++ b/backend/migrations/00001_initial.sql @@ -80,49 +80,6 @@ CREATE TABLE participant_traits ( PRIMARY KEY (participant_id, trait_id) ); -INSERT INTO users (email, password_hash, role) -VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer'); - -INSERT INTO traits (name, normalized) VALUES - ('Go', 'go'), ('PostgreSQL', 'postgresql'), ('Docker', 'docker'), - ('React', 'react'), ('TypeScript', 'typescript'), ('Kubernetes', 'kubernetes'), - ('Backend', 'backend'); - -INSERT INTO job_postings (title, description, seniority, position) VALUES - ('Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.', 'Senior', 'Backend Engineer'), - ('Full-stack Product Engineer', 'Deliver accessible operational tools from database to browser.', 'Medior', 'Full-stack Engineer'); - -INSERT INTO job_posting_labels (job_posting_id, trait_id) -SELECT 1, id FROM traits WHERE normalized IN ('backend', 'go') -ON CONFLICT DO NOTHING; -INSERT INTO job_required_skills (job_posting_id, trait_id) -SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker'); -INSERT INTO job_additional_skills (job_posting_id, trait_id) -SELECT 1, id FROM traits WHERE normalized = 'kubernetes'; - -INSERT INTO job_posting_labels (job_posting_id, trait_id) -SELECT 2, id FROM traits WHERE normalized IN ('react', 'typescript'); -INSERT INTO job_required_skills (job_posting_id, trait_id) -SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript'); -INSERT INTO job_additional_skills (job_posting_id, trait_id) -SELECT 2, id FROM traits WHERE normalized = 'docker'; - -INSERT INTO questions (job_id, text, type) VALUES - (1, 'How do you design graceful shutdown for an HTTP service?', 'open'), - (1, 'Which construct protects shared data across goroutines?', 'radio'), - (2, 'Review this component and identify unnecessary renders.', 'code_review'); -INSERT INTO question_options (question_id, text) VALUES - (2, 'sync.Mutex'), (2, 'context.Context'), (2, 'http.Client'); - -INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename) VALUES - ('Ada', 'Lovelace', '1990-12-10', 'ada@example.com', '+32 470 00 00 01', encode('Senior backend engineer with Go, PostgreSQL and Docker experience.', 'escape')::bytea, 'ada-cv.txt'), - ('Grace', 'Hopper', '1988-12-09', 'grace@example.com', '+32 470 00 00 02', encode('Full-stack developer working with Go, React and TypeScript.', 'escape')::bytea, 'grace-cv.txt'); - -INSERT INTO participant_traits (participant_id, trait_id) -SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker'); -INSERT INTO participant_traits (participant_id, trait_id) -SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript'); - -- +goose Down DROP TABLE IF EXISTS participant_traits; DROP TABLE IF EXISTS job_additional_skills; diff --git a/backend/migrations/00004_question_reference_answer.sql b/backend/migrations/00004_question_reference_answer.sql index c2f7f3a..06644c8 100644 --- a/backend/migrations/00004_question_reference_answer.sql +++ b/backend/migrations/00004_question_reference_answer.sql @@ -2,12 +2,5 @@ ALTER TABLE questions ADD COLUMN reference_answer TEXT NOT NULL DEFAULT ''; -UPDATE questions -SET reference_answer = CASE - WHEN type = 'open' THEN 'Use context cancellation, stop accepting work, drain active requests, and close dependencies within a deadline.' - WHEN type = 'code_review' THEN 'Identify unstable dependencies or state updates that cause avoidable renders and propose memoization only where measured.' - ELSE reference_answer -END; - -- +goose Down ALTER TABLE questions DROP COLUMN reference_answer; diff --git a/backend/migrations/00005_question_code_snippet.sql b/backend/migrations/00005_question_code_snippet.sql index e36b12a..aa049fe 100644 --- a/backend/migrations/00005_question_code_snippet.sql +++ b/backend/migrations/00005_question_code_snippet.sql @@ -2,9 +2,5 @@ ALTER TABLE questions ADD COLUMN code_snippet TEXT NOT NULL DEFAULT ''; -UPDATE questions -SET code_snippet = E'function CandidateList({ candidates }) {\n return candidates.map(candidate => (\n \n ));\n}' -WHERE type = 'code_review' AND code_snippet = ''; - -- +goose Down ALTER TABLE questions DROP COLUMN code_snippet; diff --git a/backend/test_review_endpoint.go b/backend/test_review_endpoint.go deleted file mode 100644 index 41724d9..0000000 --- a/backend/test_review_endpoint.go +++ /dev/null @@ -1,125 +0,0 @@ -package main - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/danyel/go-guess/backend/internal/security" - "github.com/danyel/go-guess/backend/internal/service/model" - "github.com/danyel/go-guess/backend/internal/web/handler" - "github.com/danyel/go-guess/backend/internal/web/router" -) - -type mockInvitationService struct{} - -func (m mockInvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) { - return []model.Invitation{{ID: 1}}, nil -} - -func (m mockInvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) { - return model.Invitation{}, nil -} - -func (m mockInvitationService) Review(ctx context.Context, jobID, invitationID uint) (model.Interview, error) { - return model.Interview{ - Invitation: model.Invitation{ID: 1, ParticipantName: "Test", ParticipantEmail: "test@example.com", Status: "completed"}, - Job: model.JobPosting{ID: 1, Title: "Job"}, - Answers: map[uint]string{1: "My answer"}, - }, nil -} - -func (m mockInvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) { - return model.Interview{}, nil -} - -func (m mockInvitationService) Accept(ctx context.Context, token string) (model.Interview, error) { - return model.Interview{}, nil -} - -func (m mockInvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error { - return nil -} - -func (m mockInvitationService) Finish(ctx context.Context, token string) (model.Interview, error) { - return model.Interview{}, nil -} - -type mockStubs struct{} - -func (mockStubs) Login(context.Context, string, string) (string, model.User, error) { - return "", model.User{}, nil -} - -func (mockStubs) List(context.Context) ([]model.JobPosting, error) { - return []model.JobPosting{}, nil -} -func (mockStubs) Get(context.Context, uint) (model.JobPosting, error) { - return model.JobPosting{}, nil -} -func (mockStubs) Create(context.Context, model.JobPosting) (model.JobPosting, error) { - return model.JobPosting{}, nil -} -func (mockStubs) Update(context.Context, uint, string, int) (model.JobPosting, error) { - return model.JobPosting{}, nil -} -func (mockStubs) AttachQuestion(context.Context, uint, uint) error { return nil } -func (mockStubs) DetachQuestion(context.Context, uint, uint) error { return nil } -func (mockStubs) Candidates(context.Context, uint) ([]model.CandidateMatch, error) { - return []model.CandidateMatch{}, nil -} - -func (mockStubs) List2(context.Context) ([]model.Participant, error) { return nil, nil } -func (mockStubs) Get2(context.Context, uint) (model.Participant, error) { - return model.Participant{}, nil -} -func (mockStubs) Create2(context.Context, model.Participant) (model.Participant, error) { - return model.Participant{}, nil -} - -func (mockStubs) List3(context.Context, string) ([]model.Question, error) { return nil, nil } -func (mockStubs) Create3(context.Context, model.Question) (model.Question, error) { - return model.Question{}, nil -} -func (mockStubs) Update3(context.Context, uint, model.Question) (model.Question, error) { - return model.Question{}, nil -} -func (mockStubs) SetDeprecated(context.Context, uint, bool) (model.Question, error) { - return model.Question{}, nil -} - -func TestReviewEndpoint(t *testing.T) { - // Setup - mockAuth := mockStubs{} - mockJobs := mockStubs{} - mockQuestions := mockStubs{} - mockParticipants := mockStubs{} - mockInvitations := mockInvitationService{} - - h := handler.New(mockAuth, mockJobs, mockQuestions, mockParticipants, mockInvitations, 10, "http://localhost:5173") - tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour) - r := router.New(h, tokens, "http://localhost:5173") - - // Create a valid token - token, err := tokens.Issue(1, "test@example.com", "interviewer") - if err != nil { - t.Fatalf("Failed to create token: %v", err) - } - - // Test the review endpoint - req := httptest.NewRequest(http.MethodGet, "/api/jobs/1/invitations/1", nil) - req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected 200, got %d. Response: %s", w.Code, w.Body.String()) - } - - if w.Header().Get("Content-Type") != "application/json" { - t.Errorf("Expected JSON content type, got %s", w.Header().Get("Content-Type")) - } -} diff --git a/docker-compose.yml b/docker-compose.yml index aff52f6..05cf9e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,8 @@ services: environment: DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable JWT_SECRET: local-development-secret-change-me-12345 - FRONTEND_URL: http://localhost:5173 + FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173} + APP_ENV: ${APP_ENV:-development} ports: - "${API_PORT:-8080}:8080" depends_on: