feat(backend): establish gin sqlite service skeleton
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.env.*
|
||||
bin/
|
||||
var/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -0,0 +1,35 @@
|
||||
# cmroubao backend API
|
||||
|
||||
Go 1.23.0、Gin 1.11.0 和 SQLite 构成的最小后端骨架。当前只提供健康检查和数据库
|
||||
迁移生命周期,不包含任务、鉴权或管理页面业务。
|
||||
|
||||
## 环境
|
||||
|
||||
- Go 1.23.0
|
||||
- `CGO_ENABLED=1`
|
||||
- PATH 中可用的 GCC
|
||||
|
||||
可选环境变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `CMROUBAO_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址;局域网监听必须显式配置 |
|
||||
| `CMROUBAO_DATABASE_PATH` | `var/cmroubao.db` | SQLite 文件路径 |
|
||||
|
||||
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
|
||||
|
||||
## 命令
|
||||
|
||||
```powershell
|
||||
$env:GOTOOLCHAIN = "local"
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build -o bin/cmroubao-api.exe ./cmd/api
|
||||
go build -o bin/cmroubao-migrate.exe ./cmd/migrate
|
||||
go run ./cmd/migrate up
|
||||
go run ./cmd/migrate status
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
服务启动后,`GET /healthz` 在数据库可用时返回 `200` 和
|
||||
`{"status":"ok"}`,不可用时返回 `503` 和 `{"status":"unavailable"}`。
|
||||
@@ -0,0 +1,13 @@
|
||||
# Scaffold provenance
|
||||
|
||||
- Generator: [Melkeydev/go-blueprint](https://github.com/Melkeydev/go-blueprint)
|
||||
- Version: `v0.10.11`
|
||||
- License: MIT
|
||||
- Reference command:
|
||||
`go-blueprint create --name backend-api --framework gin --driver sqlite --git skip`
|
||||
|
||||
The generator was run once outside this repository. Its current output targets Go 1.25 and
|
||||
Gin 1.12 and includes demo routes, permissive CORS, implicit `.env` loading, a package-level
|
||||
database singleton, and fatal health-check behavior. Those defaults were not copied. The checked-in
|
||||
code retains only the minimal command/internal package shape and is independently constrained by
|
||||
this project's architecture and tests.
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/config"
|
||||
"cmroubao/backend-api/internal/platform/database"
|
||||
"cmroubao/backend-api/internal/transport/httpapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Printf("cmroubao API stopped: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load(os.LookupEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
startupContext, cancelStartup := context.WithTimeout(
|
||||
context.Background(),
|
||||
cfg.ReadTimeout,
|
||||
)
|
||||
defer cancelStartup()
|
||||
db, err := database.Open(startupContext, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return errors.New("database startup failed")
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
log.Print("database close failed")
|
||||
}
|
||||
}()
|
||||
|
||||
router, err := httpapi.NewRouter(db, func(event string) {
|
||||
log.Print(event)
|
||||
})
|
||||
if err != nil {
|
||||
return errors.New("HTTP router setup failed")
|
||||
}
|
||||
server := httpapi.NewServer(cfg, router)
|
||||
|
||||
signalContext, stopSignals := signal.NotifyContext(
|
||||
context.Background(),
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
defer stopSignals()
|
||||
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrors <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("HTTP server failed")
|
||||
case <-signalContext.Done():
|
||||
return shutdownServer(server, serverErrors, cfg.ShutdownTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownServer(
|
||||
server *http.Server,
|
||||
serverErrors <-chan error,
|
||||
timeout time.Duration,
|
||||
) error {
|
||||
shutdownContext, cancelShutdown := context.WithTimeout(
|
||||
context.Background(),
|
||||
timeout,
|
||||
)
|
||||
defer cancelShutdown()
|
||||
shutdownErr := server.Shutdown(shutdownContext)
|
||||
if shutdownErr != nil {
|
||||
if err := server.Close(); err != nil {
|
||||
return errors.New("HTTP server force close failed")
|
||||
}
|
||||
}
|
||||
|
||||
waitTimeout := timeout
|
||||
if waitTimeout > time.Second {
|
||||
waitTimeout = time.Second
|
||||
}
|
||||
timer := time.NewTimer(waitTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return errors.New("HTTP server failed during shutdown")
|
||||
}
|
||||
case <-timer.C:
|
||||
_ = server.Close()
|
||||
return errors.New("HTTP server did not stop")
|
||||
}
|
||||
if shutdownErr != nil {
|
||||
return errors.New("HTTP server graceful shutdown timed out")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestShutdownServerForceClosesAfterGracefulTimeout(t *testing.T) {
|
||||
handlerStarted := make(chan struct{})
|
||||
releaseHandler := make(chan struct{})
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
close(handlerStarted)
|
||||
<-releaseHandler
|
||||
}),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrors <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
requestFinished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestFinished)
|
||||
client := http.Client{Timeout: time.Second}
|
||||
response, err := client.Get("http://" + listener.Addr().String())
|
||||
if err == nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-handlerStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("handler did not start")
|
||||
}
|
||||
|
||||
err = shutdownServer(server, serverErrors, 10*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("shutdownServer() error = nil")
|
||||
}
|
||||
close(releaseHandler)
|
||||
select {
|
||||
case <-requestFinished:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("request did not finish after force close")
|
||||
}
|
||||
connection, dialErr := net.DialTimeout(
|
||||
"tcp",
|
||||
listener.Addr().String(),
|
||||
100*time.Millisecond,
|
||||
)
|
||||
if dialErr == nil {
|
||||
_ = connection.Close()
|
||||
t.Fatal("listener still accepted connections after force close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownServerAcceptsNormalServerClose(t *testing.T) {
|
||||
serverErrors := make(chan error, 1)
|
||||
serverErrors <- http.ErrServerClosed
|
||||
server := &http.Server{}
|
||||
|
||||
if err := shutdownServer(server, serverErrors, time.Second); err != nil &&
|
||||
!errors.Is(err, http.ErrServerClosed) {
|
||||
t.Fatalf("shutdownServer() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/config"
|
||||
"cmroubao/backend-api/internal/platform/database"
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
log.Printf("migration command failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
if len(arguments) != 1 {
|
||||
return errors.New("usage: migrate <up|down|status>")
|
||||
}
|
||||
command := arguments[0]
|
||||
if command != "up" && command != "down" && command != "status" {
|
||||
return errors.New("migration command must be up, down, or status")
|
||||
}
|
||||
|
||||
databasePath, err := config.LoadDatabasePath(os.LookupEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := database.Open(ctx, databasePath)
|
||||
if err != nil {
|
||||
return errors.New("database startup failed")
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
log.Print("database close failed")
|
||||
}
|
||||
}()
|
||||
|
||||
runner, err := migration.New(db)
|
||||
if err != nil {
|
||||
return errors.New("migration setup failed")
|
||||
}
|
||||
switch command {
|
||||
case "up":
|
||||
count, err := runner.Up(ctx)
|
||||
if err != nil {
|
||||
return errors.New("migration up failed")
|
||||
}
|
||||
fmt.Printf("applied=%d\n", count)
|
||||
case "down":
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
return errors.New("migration down failed")
|
||||
}
|
||||
fmt.Println("rolled_back=1")
|
||||
case "status":
|
||||
statuses, err := runner.Status(ctx)
|
||||
if err != nil {
|
||||
return errors.New("migration status failed")
|
||||
}
|
||||
for _, status := range statuses {
|
||||
state := "pending"
|
||||
if status.Applied {
|
||||
state = "applied"
|
||||
}
|
||||
fmt.Printf("version=%d state=%s\n", status.Version, state)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRunRejectsMissingAndUnknownCommandsBeforeOpeningDatabase(t *testing.T) {
|
||||
tests := [][]string{
|
||||
nil,
|
||||
{"unknown"},
|
||||
{"up", "extra"},
|
||||
}
|
||||
for _, arguments := range tests {
|
||||
if err := run(arguments); err == nil {
|
||||
t.Fatalf("run(%v) error = nil", arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
module cmroubao/backend-api
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/mattn/go-sqlite3 v1.14.48
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
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/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR"
|
||||
DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH"
|
||||
|
||||
defaultHTTPAddress = "127.0.0.1:8080"
|
||||
defaultDatabasePath = "var/cmroubao.db"
|
||||
)
|
||||
|
||||
type LookupEnvironment func(string) (string, bool)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddress string
|
||||
DatabasePath string
|
||||
ReadHeaderTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
}
|
||||
|
||||
func Load(lookup LookupEnvironment) (Config, error) {
|
||||
httpAddress, err := environmentValue(
|
||||
lookup,
|
||||
HTTPAddressEnvironment,
|
||||
defaultHTTPAddress,
|
||||
)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateHTTPAddress(httpAddress); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
databasePath, err := environmentValue(
|
||||
lookup,
|
||||
DatabasePathEnvironment,
|
||||
defaultDatabasePath,
|
||||
)
|
||||
databasePath, err = validatedDatabasePath(databasePath, err)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return Config{
|
||||
HTTPAddress: httpAddress,
|
||||
DatabasePath: filepath.Clean(databasePath),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func LoadDatabasePath(lookup LookupEnvironment) (string, error) {
|
||||
databasePath, err := environmentValue(
|
||||
lookup,
|
||||
DatabasePathEnvironment,
|
||||
defaultDatabasePath,
|
||||
)
|
||||
return validatedDatabasePath(databasePath, err)
|
||||
}
|
||||
|
||||
func environmentValue(
|
||||
lookup LookupEnvironment,
|
||||
name string,
|
||||
defaultValue string,
|
||||
) (string, error) {
|
||||
value, exists := lookup(name)
|
||||
if !exists {
|
||||
return defaultValue, nil
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New(name + " must not be blank")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateHTTPAddress(address string) error {
|
||||
host, portValue, err := net.SplitHostPort(address)
|
||||
if err != nil || strings.TrimSpace(host) == "" {
|
||||
return errors.New(HTTPAddressEnvironment + " must include a host and port")
|
||||
}
|
||||
port, err := strconv.Atoi(portValue)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return errors.New(HTTPAddressEnvironment + " port must be between 1 and 65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatedDatabasePath(path string, previousError error) (string, error) {
|
||||
if previousError != nil {
|
||||
return "", previousError
|
||||
}
|
||||
if strings.ContainsRune(path, '\x00') {
|
||||
return "", errors.New(
|
||||
DatabasePathEnvironment + " contains an invalid character",
|
||||
)
|
||||
}
|
||||
lowerPath := strings.ToLower(path)
|
||||
cleanPath := filepath.Clean(path)
|
||||
extension := strings.ToLower(filepath.Ext(cleanPath))
|
||||
if cleanPath == "." ||
|
||||
cleanPath == string(filepath.Separator) ||
|
||||
lowerPath == ":memory:" ||
|
||||
strings.HasPrefix(lowerPath, "file:") ||
|
||||
(extension != ".db" && extension != ".sqlite" && extension != ".sqlite3") {
|
||||
return "", errors.New(
|
||||
DatabasePathEnvironment + " must be a SQLite file path",
|
||||
)
|
||||
}
|
||||
return cleanPath, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadUsesSafeDefaults(t *testing.T) {
|
||||
cfg, err := Load(emptyEnvironment)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.HTTPAddress != "127.0.0.1:8080" {
|
||||
t.Fatalf("HTTPAddress = %q", cfg.HTTPAddress)
|
||||
}
|
||||
if cfg.DatabasePath != filepath.FromSlash("var/cmroubao.db") {
|
||||
t.Fatalf("DatabasePath = %q", cfg.DatabasePath)
|
||||
}
|
||||
if cfg.ReadHeaderTimeout <= 0 ||
|
||||
cfg.ReadTimeout <= 0 ||
|
||||
cfg.WriteTimeout <= 0 ||
|
||||
cfg.IdleTimeout <= 0 ||
|
||||
cfg.ShutdownTimeout <= 0 ||
|
||||
cfg.MaxHeaderBytes <= 0 {
|
||||
t.Fatal("server safety limits must all be positive")
|
||||
}
|
||||
if cfg.ShutdownTimeout > 30*time.Second {
|
||||
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
||||
values := map[string]string{
|
||||
HTTPAddressEnvironment: "192.0.2.10:9090",
|
||||
DatabasePathEnvironment: "tmp/test.db",
|
||||
}
|
||||
|
||||
cfg, err := Load(mapEnvironment(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.HTTPAddress != values[HTTPAddressEnvironment] {
|
||||
t.Fatalf("HTTPAddress = %q", cfg.HTTPAddress)
|
||||
}
|
||||
if cfg.DatabasePath != filepath.Clean(values[DatabasePathEnvironment]) {
|
||||
t.Fatalf("DatabasePath = %q", cfg.DatabasePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
}{
|
||||
{
|
||||
name: "blank explicit address",
|
||||
values: map[string]string{
|
||||
HTTPAddressEnvironment: " ",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "address without host",
|
||||
values: map[string]string{
|
||||
HTTPAddressEnvironment: ":8080",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid port",
|
||||
values: map[string]string{
|
||||
HTTPAddressEnvironment: "127.0.0.1:70000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "memory database",
|
||||
values: map[string]string{
|
||||
DatabasePathEnvironment: ":memory:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "database DSN",
|
||||
values: map[string]string{
|
||||
DatabasePathEnvironment: "file:test.db?mode=memory",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "database directory",
|
||||
values: map[string]string{
|
||||
DatabasePathEnvironment: "./",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non SQLite extension",
|
||||
values: map[string]string{
|
||||
DatabasePathEnvironment: "var/database.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := Load(mapEnvironment(test.values)); err == nil {
|
||||
t.Fatal("Load() error = nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatabasePathIgnoresHTTPConfiguration(t *testing.T) {
|
||||
path, err := LoadDatabasePath(mapEnvironment(map[string]string{
|
||||
HTTPAddressEnvironment: "invalid",
|
||||
DatabasePathEnvironment: "tmp/migration.sqlite",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDatabasePath() error = %v", err)
|
||||
}
|
||||
if path != filepath.Clean("tmp/migration.sqlite") {
|
||||
t.Fatalf("path = %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func emptyEnvironment(string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func mapEnvironment(values map[string]string) LookupEnvironment {
|
||||
return func(name string) (string, bool) {
|
||||
value, exists := values[name]
|
||||
return value, exists
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const busyTimeoutMilliseconds = 5000
|
||||
|
||||
type safeError struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e safeError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e safeError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
absolutePath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("resolve SQLite path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o700); err != nil {
|
||||
return nil, errors.New("create SQLite directory")
|
||||
}
|
||||
if info, err := os.Stat(absolutePath); err == nil && info.IsDir() {
|
||||
return nil, errors.New("SQLite path is a directory")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errors.New("inspect SQLite path")
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dataSourceName(absolutePath))
|
||||
if err != nil {
|
||||
return nil, errors.New("initialize SQLite driver")
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, safeError{
|
||||
message: "connect to SQLite",
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func dataSourceName(absolutePath string) string {
|
||||
slashPath := filepath.ToSlash(absolutePath)
|
||||
if filepath.VolumeName(absolutePath) != "" &&
|
||||
!strings.HasPrefix(slashPath, "/") {
|
||||
slashPath = "/" + slashPath
|
||||
}
|
||||
dsnURL := &url.URL{
|
||||
Scheme: "file",
|
||||
Path: slashPath,
|
||||
}
|
||||
query := dsnURL.Query()
|
||||
query.Set("_busy_timeout", "5000")
|
||||
query.Set("_foreign_keys", "on")
|
||||
query.Set("_journal_mode", "WAL")
|
||||
query.Set("_txlock", "immediate")
|
||||
dsnURL.RawQuery = query.Encode()
|
||||
return dsnURL.String()
|
||||
}
|
||||
|
||||
func isSafeDataSourceName(value string) bool {
|
||||
lowerValue := strings.ToLower(value)
|
||||
return strings.HasPrefix(lowerValue, "file:") &&
|
||||
strings.Contains(lowerValue, "_busy_timeout=") &&
|
||||
strings.Contains(lowerValue, "_foreign_keys=") &&
|
||||
strings.Contains(lowerValue, "_journal_mode=wal") &&
|
||||
strings.Contains(lowerValue, "_txlock=immediate")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenConfiguresSQLiteAndClosesCleanly(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "test.db")
|
||||
db, err := Open(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err))
|
||||
}
|
||||
|
||||
assertPragmaInt(t, db, "foreign_keys", 1)
|
||||
assertPragmaInt(t, db, "busy_timeout", busyTimeoutMilliseconds)
|
||||
assertPragmaString(t, db, "journal_mode", "wal")
|
||||
|
||||
if db.Stats().MaxOpenConnections != 1 {
|
||||
t.Fatalf("MaxOpenConnections = %d", db.Stats().MaxOpenConnections)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if err := db.PingContext(context.Background()); err == nil {
|
||||
t.Fatal("PingContext() after Close() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEnforcesForeignKeys(t *testing.T) {
|
||||
db, err := Open(
|
||||
context.Background(),
|
||||
filepath.Join(t.TempDir(), "foreign-keys.db"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err))
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE parent (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE child (
|
||||
id INTEGER PRIMARY KEY,
|
||||
parent_id INTEGER NOT NULL REFERENCES parent(id)
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("create tables: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO child (id, parent_id) VALUES (1, 999)",
|
||||
); err == nil {
|
||||
t.Fatal("foreign key violation error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataSourceNameIncludesRequiredOptions(t *testing.T) {
|
||||
dsn := dataSourceName(filepath.Join(t.TempDir(), "test.db"))
|
||||
if !isSafeDataSourceName(dsn) {
|
||||
t.Fatalf("unsafe DSN options")
|
||||
}
|
||||
}
|
||||
|
||||
func assertPragmaInt(t *testing.T, db *sql.DB, name string, want int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil {
|
||||
t.Fatalf("PRAGMA %s: %v", name, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("PRAGMA %s = %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPragmaString(t *testing.T, db *sql.DB, name, want string) {
|
||||
t.Helper()
|
||||
var got string
|
||||
if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil {
|
||||
t.Fatalf("PRAGMA %s: %v", name, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("PRAGMA %s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"cmroubao/backend-api/migrations"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Version int64
|
||||
Applied bool
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
provider *goose.Provider
|
||||
}
|
||||
|
||||
func New(db *sql.DB) (*Runner, error) {
|
||||
provider, err := goose.NewProvider(
|
||||
goose.DialectSQLite3,
|
||||
db,
|
||||
migrations.Files,
|
||||
goose.WithDisableGlobalRegistry(true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Runner{provider: provider}, nil
|
||||
}
|
||||
|
||||
func (r *Runner) Up(ctx context.Context) (int, error) {
|
||||
results, err := r.provider.Up(ctx)
|
||||
return len(results), err
|
||||
}
|
||||
|
||||
func (r *Runner) Down(ctx context.Context) error {
|
||||
_, err := r.provider.Down(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Runner) Status(ctx context.Context) ([]Status, error) {
|
||||
results, err := r.provider.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statuses := make([]Status, 0, len(results))
|
||||
for _, result := range results {
|
||||
statuses = append(statuses, Status{
|
||||
Version: result.Source.Version,
|
||||
Applied: result.State == goose.StateApplied,
|
||||
})
|
||||
}
|
||||
return statuses, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/platform/database"
|
||||
)
|
||||
|
||||
func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
db, err := database.Open(
|
||||
context.Background(),
|
||||
filepath.Join(t.TempDir(), "migration.db"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
runner, err := New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
applied, err := runner.Up(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("Up() applied = %d, want 1", applied)
|
||||
}
|
||||
assertStatus(t, runner, true)
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("second Up() error = %v", err)
|
||||
}
|
||||
if applied != 0 {
|
||||
t.Fatalf("second Up() applied = %d, want 0", applied)
|
||||
}
|
||||
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down() error = %v", err)
|
||||
}
|
||||
assertStatus(t, runner, false)
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("final Up() error = %v", err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("final Up() applied = %d, want 1", applied)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatus(t *testing.T, runner *Runner, applied bool) {
|
||||
t.Helper()
|
||||
statuses, err := runner.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Status() error = %v", err)
|
||||
}
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("Status() count = %d, want 1", len(statuses))
|
||||
}
|
||||
if statuses[0].Version != 1 || statuses[0].Applied != applied {
|
||||
t.Fatalf("Status() = %+v", statuses[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DatabasePinger interface {
|
||||
PingContext(context.Context) error
|
||||
}
|
||||
|
||||
type EventLogger func(string)
|
||||
|
||||
func NewRouter(
|
||||
database DatabasePinger,
|
||||
logEvent EventLogger,
|
||||
) (http.Handler, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("database pinger is required")
|
||||
}
|
||||
if logEvent == nil {
|
||||
return nil, errors.New("event logger is required")
|
||||
}
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.New()
|
||||
router.Use(requestIDMiddleware())
|
||||
router.Use(safeRecovery(logEvent))
|
||||
router.HandleMethodNotAllowed = true
|
||||
if err := router.SetTrustedProxies(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
router.GET("/healthz", healthHandler(database))
|
||||
router.NoRoute(func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusNotFound, errorResponse(
|
||||
ctx,
|
||||
"NOT_FOUND",
|
||||
"resource not found",
|
||||
))
|
||||
})
|
||||
router.NoMethod(func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusMethodNotAllowed, errorResponse(
|
||||
ctx,
|
||||
"METHOD_NOT_ALLOWED",
|
||||
"method not allowed",
|
||||
))
|
||||
})
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func safeRecovery(logEvent EventLogger) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
logEvent("HTTP handler panic recovered")
|
||||
ctx.AbortWithStatusJSON(
|
||||
http.StatusInternalServerError,
|
||||
errorResponse(
|
||||
ctx,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
),
|
||||
)
|
||||
}
|
||||
}()
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
requestID := newRequestID()
|
||||
ctx.Set(requestIDContextKey, requestID)
|
||||
ctx.Header(requestIDHeader, requestID)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func healthHandler(database DatabasePinger) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
pingContext, cancel := context.WithTimeout(ctx.Request.Context(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
if err := database.PingContext(pingContext); err != nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func errorResponse(ctx *gin.Context, code, message string) gin.H {
|
||||
requestID, _ := ctx.Get(requestIDContextKey)
|
||||
return gin.H{
|
||||
"error": gin.H{
|
||||
"code": code,
|
||||
"message": message,
|
||||
"retryable": false,
|
||||
"details": gin.H{},
|
||||
},
|
||||
"request_id": requestID,
|
||||
}
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
now := uint64(time.Now().UnixNano())
|
||||
binary.BigEndian.PutUint64(value[:8], now)
|
||||
binary.BigEndian.PutUint64(value[8:], fallbackRequestID.Add(1))
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf(
|
||||
"%08x-%04x-%04x-%04x-%012x",
|
||||
value[0:4],
|
||||
value[4:6],
|
||||
value[6:8],
|
||||
value[8:10],
|
||||
value[10:16],
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
requestIDContextKey = "request_id"
|
||||
requestIDHeader = "X-Request-ID"
|
||||
)
|
||||
|
||||
var fallbackRequestID atomic.Uint64
|
||||
@@ -0,0 +1,199 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakePinger struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (p fakePinger) PingContext(context.Context) error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
func TestHealthzReturnsStableHealthyResponse(t *testing.T) {
|
||||
router, err := NewRouter(fakePinger{}, discardEvent)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
|
||||
response := performRequest(t, router, http.MethodGet, "/healthz")
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
assertJSON(t, response, map[string]any{"status": "ok"})
|
||||
if response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q", response.Header().Get("Cache-Control"))
|
||||
}
|
||||
if response.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal("default CORS must remain disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzReturns503WithoutLeakingDatabaseError(t *testing.T) {
|
||||
router, err := NewRouter(fakePinger{
|
||||
err: errors.New("private database path and driver details"),
|
||||
}, discardEvent)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
|
||||
response := performRequest(t, router, http.MethodGet, "/healthz")
|
||||
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "private database") {
|
||||
t.Fatal("health response leaked the database error")
|
||||
}
|
||||
assertJSON(t, response, map[string]any{"status": "unavailable"})
|
||||
}
|
||||
|
||||
func TestUnknownRouteAndMethodUseStableErrors(t *testing.T) {
|
||||
router, err := NewRouter(fakePinger{}, discardEvent)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
|
||||
notFound := performRequest(t, router, http.MethodGet, "/missing")
|
||||
if notFound.Code != http.StatusNotFound {
|
||||
t.Fatalf("not found status = %d", notFound.Code)
|
||||
}
|
||||
assertErrorCode(t, notFound, "NOT_FOUND")
|
||||
|
||||
notAllowed := performRequest(t, router, http.MethodPost, "/healthz")
|
||||
if notAllowed.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("not allowed status = %d", notAllowed.Code)
|
||||
}
|
||||
assertErrorCode(t, notAllowed, "METHOD_NOT_ALLOWED")
|
||||
}
|
||||
|
||||
func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T) {
|
||||
var events []string
|
||||
router, err := NewRouter(
|
||||
panicPinger{},
|
||||
func(event string) {
|
||||
events = append(events, event)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
request.Header.Set("Authorization", "Bearer private-token")
|
||||
request.Header.Set("Cookie", "session=private-cookie")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
assertErrorCode(t, response, "INTERNAL_ERROR")
|
||||
if len(events) != 1 || events[0] != "HTTP handler panic recovered" {
|
||||
t.Fatalf("events = %#v", events)
|
||||
}
|
||||
eventText := strings.Join(events, " ")
|
||||
if strings.Contains(eventText, "private") ||
|
||||
strings.Contains(eventText, "Bearer") ||
|
||||
strings.Contains(eventText, "session") {
|
||||
t.Fatalf("event log leaked request or panic content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterRequiresDependencies(t *testing.T) {
|
||||
if _, err := NewRouter(nil, discardEvent); err == nil {
|
||||
t.Fatal("NewRouter(nil database) error = nil")
|
||||
}
|
||||
if _, err := NewRouter(fakePinger{}, nil); err == nil {
|
||||
t.Fatal("NewRouter(nil logger) error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func performRequest(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
method string,
|
||||
path string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func assertJSON(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
want map[string]any,
|
||||
) {
|
||||
t.Helper()
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("JSON = %#v, want %#v", got, want)
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
if got[key] != wantValue {
|
||||
t.Fatalf("JSON[%q] = %#v, want %#v", key, got[key], wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertErrorCode(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
want string,
|
||||
) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode error JSON: %v", err)
|
||||
}
|
||||
if body.Error.Code != want {
|
||||
t.Fatalf("error code = %q, want %q", body.Error.Code, want)
|
||||
}
|
||||
if body.Error.Retryable {
|
||||
t.Fatal("retryable = true")
|
||||
}
|
||||
if body.Error.Details == nil || len(body.Error.Details) != 0 {
|
||||
t.Fatalf("details = %#v", body.Error.Details)
|
||||
}
|
||||
if !requestIDPattern.MatchString(body.RequestID) {
|
||||
t.Fatalf("request_id = %q", body.RequestID)
|
||||
}
|
||||
if response.Header().Get(requestIDHeader) != body.RequestID {
|
||||
t.Fatalf("request ID header does not match body")
|
||||
}
|
||||
}
|
||||
|
||||
type panicPinger struct{}
|
||||
|
||||
func (panicPinger) PingContext(context.Context) error {
|
||||
panic("private failure detail")
|
||||
}
|
||||
|
||||
func discardEvent(string) {}
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(
|
||||
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cmroubao/backend-api/internal/config"
|
||||
)
|
||||
|
||||
func NewServer(cfg config.Config, handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.MaxHeaderBytes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/config"
|
||||
)
|
||||
|
||||
func TestNewServerAppliesAllSafetyLimits(t *testing.T) {
|
||||
cfg, err := config.Load(func(string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
|
||||
server := NewServer(cfg, handler)
|
||||
|
||||
if server.Addr != cfg.HTTPAddress || server.Handler == nil {
|
||||
t.Fatalf("server = %+v", server)
|
||||
}
|
||||
if server.ReadHeaderTimeout != cfg.ReadHeaderTimeout ||
|
||||
server.ReadTimeout != cfg.ReadTimeout ||
|
||||
server.WriteTimeout != cfg.WriteTimeout ||
|
||||
server.IdleTimeout != cfg.IdleTimeout ||
|
||||
server.MaxHeaderBytes != cfg.MaxHeaderBytes {
|
||||
t.Fatal("server safety limits do not match config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSupportsBoundedGracefulShutdown(t *testing.T) {
|
||||
cfg, err := config.Load(func(string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
server := NewServer(
|
||||
cfg,
|
||||
http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrors <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
client := http.Client{Timeout: time.Second}
|
||||
response, err := client.Get("http://" + listener.Addr().String())
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
t.Fatalf("GET server: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownContext); err != nil {
|
||||
t.Fatalf("Shutdown() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Fatalf("Serve() error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve() did not stop after Shutdown()")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
-- T-201 verifies the migration lifecycle without defining later business tables.
|
||||
SELECT 1;
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains the versioned SQL migrations used by both commands and tests.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var Files embed.FS
|
||||
Reference in New Issue
Block a user