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), ®) 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) }