test: add self-contained integration environment
Separate schema migrations from environment-specific fixtures, run the full API workflow against a disposable PostgreSQL Testcontainer, and provide seeded development and UI test environments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
6153017b62
commit
6969f79015
18 changed files with 772 additions and 269 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
33
backend/cmd/seed/main.go
Normal file
33
backend/cmd/seed/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
116
backend/go.sum
116
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=
|
||||
|
|
|
|||
9
backend/internal/database/seed/common.sql
Normal file
9
backend/internal/database/seed/common.sql
Normal file
|
|
@ -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;
|
||||
87
backend/internal/database/seed/development.sql
Normal file
87
backend/internal/database/seed/development.sql
Normal file
|
|
@ -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 <CandidateCard key={index} candidate={candidate} />\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;
|
||||
38
backend/internal/database/seed/seed.go
Normal file
38
backend/internal/database/seed/seed.go
Normal file
|
|
@ -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
|
||||
}
|
||||
40
backend/internal/database/seed/test.sql
Normal file
40
backend/internal/database/seed/test.sql
Normal file
|
|
@ -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;
|
||||
272
backend/internal/integration/api_test.go
Normal file
272
backend/internal/integration/api_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <CandidateCard candidate={candidate} />\n ));\n}'
|
||||
WHERE type = 'code_review' AND code_snippet = '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE questions DROP COLUMN code_snippet;
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue