Files
2026-09-20 14:16:41 +02:00

248 lines
7.9 KiB
Go

package api_test
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"halcyon/runner"
)
// player is a registered account talking to the API.
type player struct {
t *testing.T
base string
key string
ship int64
}
func newPlayer(t *testing.T, base, name string) *player {
t.Helper()
resp, err := http.Post(base+"/register", "application/json", strings.NewReader(`{"name":"`+name+`"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r struct {
APIKey string `json:"api_key"`
ShipID int64 `json:"ship_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil || r.APIKey == "" {
t.Fatalf("register %s: %v", name, err)
}
return &player{t: t, base: base, key: r.APIKey, ship: r.ShipID}
}
// do sends a request and returns the body, failing unless the status is want.
func (p *player) do(method, path string, body any, want int) []byte {
p.t.Helper()
var rdr io.Reader
if body != nil {
if s, ok := body.(string); ok {
rdr = strings.NewReader(s)
} else {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
}
req, _ := http.NewRequest(method, p.base+path, rdr)
if p.key != "" {
req.Header.Set("Authorization", "Bearer "+p.key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
p.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
p.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
}
return b
}
type slotRow struct {
Slot int `json:"slot"`
Name string `json:"name"`
Builtin bool `json:"builtin"`
Empty bool `json:"empty"`
Source string `json:"source"`
}
func (p *player) slots() []slotRow {
var rows []slotRow
json.Unmarshal(p.do("GET", "/slots", nil, 200), &rows)
return rows
}
func (p *player) slot(n int) slotRow {
var r slotRow
json.Unmarshal(p.do("GET", "/slots/"+itoa(int64(n)), nil, 200), &r)
return r
}
func (p *player) shipStatus() (status string, programBytes int) {
var ships []struct {
Status string `json:"status"`
ProgramBytes int `json:"program_bytes"`
}
json.Unmarshal(p.do("GET", "/ships", nil, 200), &ships)
return ships[0].Status, ships[0].ProgramBytes
}
func TestSlotsListAndExamples(t *testing.T) {
srv, _ := newSite(t, "")
a := newPlayer(t, srv.URL, "ann")
rows := a.slots()
if len(rows) != 16 {
t.Fatalf("%d slots, want 16", len(rows))
}
for i, want := range []string{"ECHO", "TELEMETRY", "PURSUE"} {
if r := rows[i]; r.Slot != i+1 || r.Name != want || !r.Builtin || r.Empty {
t.Errorf("slot %d = %+v, want the %s example", i+1, r, want)
}
}
for _, r := range rows[3:] {
if r.Builtin || !r.Empty || r.Name != "" {
t.Errorf("slot %d should start empty: %+v", r.Slot, r)
}
}
if s := a.slot(2); !s.Builtin || !strings.Contains(s.Source, "TELEMETRY") {
t.Errorf("slot 2 = %+v", s)
}
if s := a.slot(9); !s.Empty || s.Source != "" {
t.Errorf("empty slot 9 = %+v", s)
}
}
func TestSlotsSaveLoadClearAndIsolation(t *testing.T) {
srv, _ := newSite(t, "")
a, b := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob")
a.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": "ldi r0, 1\nhalt"}, 200)
if s := a.slot(5); s.Name != "MINER" || s.Source != "ldi r0, 1\nhalt" || s.Empty || s.Builtin {
t.Fatalf("saved slot = %+v", s)
}
if r := a.slots()[4]; r.Name != "MINER" || r.Empty {
t.Fatalf("listing = %+v", r)
}
// Saving again replaces it, including the name.
a.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200)
if s := a.slot(5); s.Name != "MINER 2" || s.Source != "halt" {
t.Fatalf("overwritten slot = %+v", s)
}
// Slots are private to their owner.
if s := b.slot(5); !s.Empty {
t.Fatalf("bob can see ann's slot: %+v", s)
}
// The examples cannot be changed or cleared.
for _, n := range []string{"1", "2", "3"} {
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 403)
a.do("DELETE", "/slots/"+n, nil, 403)
}
// Bad slot numbers and bodies.
for _, n := range []string{"0", "17", "abc", "-1"} {
a.do("GET", "/slots/"+n, nil, 400)
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 400)
}
a.do("PUT", "/slots/6", map[string]string{"name": " ", "source": "halt"}, 400)
a.do("PUT", "/slots/6", map[string]string{"name": strings.Repeat("x", 41), "source": "halt"}, 400)
a.do("PUT", "/slots/6", "not json", 400)
a.do("PUT", "/slots/6", map[string]string{"name": "BIG", "source": strings.Repeat("x", 64<<10+1)}, 413)
a.do("PUT", "/slots/6", map[string]string{"name": "OK", "source": strings.Repeat("x", 64<<10)}, 200)
// Clearing.
a.do("DELETE", "/slots/5", nil, 204)
a.do("DELETE", "/slots/5", nil, 204) // clearing an empty slot is fine
if s := a.slot(5); !s.Empty {
t.Fatalf("cleared slot = %+v", s)
}
// Everything needs a key.
anon := &player{t: t, base: srv.URL}
anon.do("GET", "/slots", nil, 401)
anon.do("PUT", "/slots/5", map[string]string{"name": "X", "source": "halt"}, 401)
}
func TestLaunchFromSlot(t *testing.T) {
srv, cfg := newSite(t, "")
launch := func(p *player, slot any, want int) []byte {
return p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]any{"slot": slot}, want)
}
// A saved slot: assembled, sealed into the ship, and launched in one step.
a := newPlayer(t, srv.URL, "ann")
a.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": "ldi r0, 1\nhalt"}, 200)
var res struct {
Slot int `json:"slot"`
ProgramBytes int `json:"program_bytes"`
}
json.Unmarshal(launch(a, 6, 202), &res)
if res.Slot != 6 || res.ProgramBytes != 8 {
t.Fatalf("launch reply = %+v", res)
}
if st, n := a.shipStatus(); st != "launching" || n != 8 {
t.Fatalf("ship is %s with %d program bytes", st, n)
}
launch(a, 6, 409) // already launching
// Things that must be refused, leaving the ship docked and its program untouched.
b := newPlayer(t, srv.URL, "bob")
b.do("PUT", "/slots/7", map[string]string{"name": "BROKEN", "source": "ldi r1, 5\nbogus r1"}, 200)
b.do("PUT", "/slots/8", map[string]string{"name": "HUGE", "source": strings.Repeat("nop\n", cfg.ProgramBytes/4+1)}, 200)
b.do("PUT", "/slots/10", map[string]string{"name": "BLANK", "source": " \n"}, 200)
for name, c := range map[string]struct {
slot any
msg string
}{
"empty slot": {9, "empty"},
"blank source": {10, "empty"},
"does not compile": {7, "line 2"},
"too large": {8, "limit"},
"out of range": {99, "1 to 16"},
"zero": {0, "1 to 16"},
} {
if body := launch(b, c.slot, 400); !strings.Contains(string(body), c.msg) {
t.Errorf("%s: error %q should mention %q", name, body, c.msg)
}
}
if st, n := b.shipStatus(); st != "inventory" || n != 0 {
t.Fatalf("a refused launch changed the ship: %s, %d bytes", st, n)
}
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", "{not json", 400)
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", nil, 409) // no slot and no sealed program: as before
// Slots are per player: ann's saved slot 6 is empty for bob.
launch(b, 6, 400)
// Ann cannot launch bob's ship with her slot.
a.do("POST", "/ships/"+itoa(b.ship)+"/launch", map[string]int{"slot": 6}, 404)
// An example slot works for anyone.
launch(b, 2, 202)
if st, n := b.shipStatus(); st != "launching" || n == 0 {
t.Fatalf("after launching the telemetry example: %s, %d bytes", st, n)
}
}
// A ship launched from a slot really flies that slot's program.
func TestSlotProgramRunsInTheBelt(t *testing.T) {
srv, cfg, st := newSiteStore(t, "")
cfg.AsteroidCount = 20
p := newPlayer(t, srv.URL, "ann")
p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 2}, 202) // TELEMETRY
if _, err := runner.RunNextDay(context.Background(), st, cfg); err != nil {
t.Fatal(err)
}
down := p.do("GET", "/ships/"+itoa(p.ship)+"/downlink", nil, 200)
if tick := binary.LittleEndian.Uint32(down[0:4]); tick != uint32(cfg.TicksPerDay-1) {
t.Fatalf("telemetry downlink reports tick %d, want the last tick %d", tick, cfg.TicksPerDay-1)
}
}