Files
Halcyon/api/api_test.go
T
2026-09-19 20:21:47 +02:00

161 lines
3.9 KiB
Go

package api_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"wh/api"
"wh/config"
"wh/runner"
"wh/sim"
"wh/store"
"wh/vm"
)
func TestEndToEnd(t *testing.T) {
ctx := context.Background()
cfg := config.Default()
cfg.AsteroidCount = 20
// Set WH_TEST_PG to a postgres:// URL to run this against PostgreSQL
// (its public schema is wiped first).
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
if pg := os.Getenv("WH_TEST_PG"); pg != "" {
db, err := sql.Open("pgx", pg)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public"); err != nil {
t.Fatal(err)
}
db.Close()
dsn = pg
}
st, err := store.Open(dsn)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(api.New(st, cfg))
defer srv.Close()
var key string
do := func(method, path string, body []byte, want int) []byte {
t.Helper()
req, _ := http.NewRequest(method, srv.URL+path, bytes.NewReader(body))
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
}
return b
}
var reg struct {
APIKey string `json:"api_key"`
ShipID int64 `json:"ship_id"`
}
json.Unmarshal(do("POST", "/register", []byte(`{"name":"ann"}`), 201), &reg)
do("POST", "/register", []byte(`{"name":"ann"}`), 409)
do("GET", "/me", nil, 401)
key = reg.APIKey
ship := "/ships/" + itoa(reg.ShipID)
// Launch needs a program first; program limits are enforced.
do("POST", ship+"/launch", nil, 409)
do("PUT", ship+"/program", []byte{1, 2, 3}, 400)
do("PUT", ship+"/program", make([]byte, cfg.ProgramBytes+4), 413)
// Echo the uplink back on the downlink, then idle.
prog, err := vm.Assemble(`
.equ TX 1024
in r1, 0x70
ldi r2, 0
beq r1, r2, idle
ldw r3, [r2]
stw r3, [r2+TX]
out 0x70, r1
idle:
yield
jmp idle
`)
if err != nil {
t.Fatal(err)
}
do("PUT", ship+"/program", prog, 204)
do("PUT", ship+"/uplink", []byte("hi"), 409) // not launched yet
do("POST", ship+"/launch", nil, 202)
do("PUT", ship+"/program", prog, 409) // frozen once launching
do("PUT", ship+"/uplink", []byte("PING"), 204)
do("PUT", ship+"/uplink", make([]byte, cfg.CommBytes+1), 413)
do("GET", ship+"/downlink", nil, 404)
res, err := runner.RunNextDay(ctx, st, cfg)
if err != nil {
t.Fatal(err)
}
if res.Day != 0 {
t.Fatalf("first run should be day 0, got %d", res.Day)
}
if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" {
t.Fatalf("downlink = %q", b[:8])
}
var ships []struct{ Status string }
json.Unmarshal(do("GET", "/ships", nil, 200), &ships)
if len(ships) != 1 || ships[0].Status != store.StatusActive {
t.Fatalf("ships = %+v", ships)
}
do("GET", "/market", nil, 200)
// A second day from the persisted snapshot must match an uninterrupted
// in-memory run: persistence may not change the simulation.
res2, err := runner.RunNextDay(ctx, st, cfg)
if err != nil {
t.Fatal(err)
}
mem := sim.NewState(cfg)
in := sim.DayInput{
Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}},
Uplinks: []sim.Uplink{{ShipID: reg.ShipID, Data: []byte("PING")}},
}
var want sim.DayResult
for d := 0; d < 2; d++ {
if want, err = mem.RunDay(cfg, in); err != nil {
t.Fatal(err)
}
in = sim.DayInput{}
}
if res2.Hash != want.Hash {
t.Fatal("state after DB round-trip differs from in-memory run")
}
// Changing the config on an existing world is refused.
cfg.Seed = 99
if _, err := runner.RunNextDay(ctx, st, cfg); err == nil {
t.Fatal("expected config mismatch error")
}
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}