package httpapi import ( "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "textmachine/platform/internal/pgstore" ) // reading_test.go: the wire shapes of the reading surface, and the rules that are only visible on // the wire — the conditional read, the compression, the aggregates that ride on one page and the // vocabulary that must never cross. func readingServer(t *testing.T, lib *fakeLibrary) http.Handler { t.Helper() return v0ServerWith(t, Deps{Library: lib, Capabilities: Capabilities{ Pairs: []LanguagePair{{Source: "zh", Target: "ru", Available: true}}, IntakeEnabled: true, IntakeMaxBytes: 64 << 20, PageSizeDefault: 100, }}) } // Every field the contract marks required, and the two numbers that were missing until 0.3.0 made // the epoch observable: the page's own revision and the structure version its ids belong to. func TestTheChapterTreeCarriesEveryRequiredField(t *testing.T) { number := 1 lib := &fakeLibrary{chapters: pgstore.ChapterPage{ Page: pgstore.Page{Revision: 1841, StructureVersion: 3, NextCursor: "next"}, Chapters: []pgstore.Chapter{{ID: "ch_1", Number: &number, UnitsTotal: 12, UnitsDone: 5, NoteCount: 2}}, }} got := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/chapters", "")) for _, k := range []string{"revision", "next_cursor", "structure_version", "chapters"} { if _, ok := got[k]; !ok { t.Errorf("ChapterPage is missing the required field %q", k) } } rows, _ := got["chapters"].([]any) row, _ := rows[0].(map[string]any) for _, k := range []string{"id", "number", "heading", "units_total", "units_done", "note_count"} { if _, ok := row[k]; !ok { t.Errorf("Chapter is missing the required field %q", k) } } // ⚠ null, ALWAYS, and it is the contract being obeyed rather than a hole: the engine's manifest // carries a rendered ordinal («Глава N») and a deployment is forbidden to put one in this field. // A client renders its own ordinal, in the language of ITS interface. // // Mutation caught: projecting the manifest's `heading` into this field. if row["heading"] != nil { t.Errorf("heading = %v, want null until a producer of real labels exists", row["heading"]) } // The per-phase split is the platform's own business; one counter reaches the wire. if _, ok := row["units_draft_done"]; ok { t.Error("a phase counter reached the chapter row") } } // A pair carries its notes WITH it, and the note carries a CODE — never the engine's flag reason. func TestAPairCarriesItsNotesAsCodesAndNotAsEngineReasons(t *testing.T) { lib := &fakeLibrary{units: pgstore.UnitPage{ Page: pgstore.Page{Revision: 1841, StructureVersion: 3}, Units: []pgstore.Unit{{ ID: "un_1", Source: "第一节", Target: "Первый раздел", State: "translated", Notes: []pgstore.Note{{ ID: "nt_1", CreatedAt: time.Unix(0, 0).UTC(), Reason: "glossary_miss", ChapterID: "ch_1", UnitID: "un_1", }}, }}, }} w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/chapters/ch_1/units", "") got := decode(t, w) rows, _ := got["units"].([]any) row, _ := rows[0].(map[string]any) for _, k := range []string{"id", "source", "target", "state", "notes"} { if _, ok := row[k]; !ok { t.Errorf("Unit is missing the required field %q", k) } } notes, _ := row["notes"].([]any) if len(notes) != 1 { t.Fatalf("notes: %v", row["notes"]) } note, _ := notes[0].(map[string]any) if note["code"] != "term_not_applied" || note["severity"] == nil || note["chapter_id"] != "ch_1" { t.Errorf("note: %v", note) } // Mutation caught: forwarding `Note.Reason` to the wire instead of mapping it. if strings.Contains(w.Body.String(), "glossary") { t.Errorf("the engine's flag reason reached the wire: %s", w.Body) } } // The aggregates describe the WHOLE bank and ride on the FIRST page only — any response to a // request with no cursor. On a later page they are ABSENT, which is the one place on this surface // where absence means "does not apply". func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) { full := pgstore.BankPage{ Page: pgstore.Page{Revision: 1841, StructureVersion: 3}, First: true, Counts: pgstore.BankCounts{Total: 300, Signed: 40, PendingDecisions: 17}, Terms: []pgstore.BankTerm{{ ID: "tm_1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found", Sense: "", }}, } lib := &fakeLibrary{bank: full} got := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank", "")) for _, k := range []string{"total", "signed", "pending_decisions", "complete", "structure_version"} { if _, ok := got[k]; !ok { t.Errorf("the first page is missing %q", k) } } term, _ := got["terms"].([]any)[0].(map[string]any) for _, k := range []string{"id", "src", "dst", "kind", "status", "origin", "sense", "since_chapter", "until_chapter"} { if _, ok := term[k]; !ok { t.Errorf("BankTerm is missing the required field %q", k) } } if term["status"] != "proposed" || term["origin"] != "found" { t.Errorf("the engine's own vocabulary reached the wire: %v", term) } lib.bank.First = false later := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank?cursor=x", "")) for _, k := range []string{"total", "signed", "pending_decisions", "complete"} { if _, ok := later[k]; ok { t.Errorf("a later page carries the whole-bank aggregate %q", k) } } } // ⚠ TWO PINS STOOD HERE and went with the model they described: a batch of per-term decisions // refused whole, and an unknown term inside one. D39.144 abolished per-term signing (owner, 16.08) // and the write path was removed on the owner's word of 22.08 — the handle they exercised does not // exist, so this is not a coverage loss. The route's absence is itself pinned: the surface table is // the one source of mounted routes and every route in it is walked below. // `GET /capabilities` is the only place a non-streaming client learns which contract it is talking // to, and the six fields are what a client is entitled to decide from before it spends anything. func TestCapabilitiesCarriesTheSixFacts(t *testing.T) { got := decode(t, call(t, readingServer(t, &fakeLibrary{}), "GET", "/v0/capabilities", "")) for _, k := range []string{"contract_version", "language_pairs", "intake_enabled", "intake_max_bytes", "export_formats", "page_size_default"} { if _, ok := got[k]; !ok { t.Errorf("Capabilities is missing the required field %q", k) } } if got["contract_version"] != ContractVersion { t.Errorf("contract_version = %v", got["contract_version"]) } pairs, _ := got["language_pairs"].([]any) pair, _ := pairs[0].(map[string]any) if pair["source"] != "zh" || pair["target"] != "ru" || pair["state"] != "available" { t.Errorf("language_pairs: %v", got["language_pairs"]) } // An empty collection is an empty array and never null: a client that had to tell them apart // would carry a branch the contract does not describe. if formats, ok := got["export_formats"].([]any); !ok || len(formats) != 0 { t.Errorf("export_formats = %v, want an empty array", got["export_formats"]) } } // The validator is bound to the WHOLE request, query string included: page two of a collection and // a delta read of it are different representations at the same revision, and a client holding one // must not be told the other has not changed. // // Mutation caught: deriving the ETag from the revision instead of from the bytes. func TestTheValidatorIsPerRepresentationAndAnswers304(t *testing.T) { lib := &fakeLibrary{chapters: pgstore.ChapterPage{Page: pgstore.Page{Revision: 1841, StructureVersion: 3}}} h := readingServer(t, lib) first := call(t, h, "GET", "/v0/books/bk_1/chapters", "") tag := first.Header().Get("ETag") if tag == "" { t.Fatal("a collection answered no ETag") } r := httptest.NewRequest("GET", "/v0/books/bk_1/chapters", nil) r.Header.Set("Authorization", "Bearer token") r.Header.Set("If-None-Match", tag) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusNotModified || w.Body.Len() != 0 { t.Fatalf("a matching validator answered %d with %d bytes", w.Code, w.Body.Len()) } if w.Header().Get("ETag") != tag { t.Error("the 304 dropped the validator the client is meant to keep using") } // A different page of the same collection at the same revision. number := 7 lib.chapters.Chapters = []pgstore.Chapter{{ID: "ch_7", Number: &number}} second := call(t, h, "GET", "/v0/books/bk_1/chapters?cursor=page2", "") if second.Header().Get("ETag") == tag { t.Error("two different representations at one revision share a validator") } } // A server MUST honour Accept-Encoding on JSON and MUST NOT compress the stream — compressing a // stream buffers it, which is the one thing this route forbids. func TestJSONIsCompressedAndTheStreamIsNot(t *testing.T) { rows := make([]pgstore.Chapter, 200) for i := range rows { n := i + 1 rows[i] = pgstore.Chapter{ID: "ch_" + strconv.Itoa(n), Number: &n, UnitsTotal: 10} } // The stream half of this test needs a book AT REST, or the pump polls until the test's deadline: // a live book's stream is meant to stay open, which is the whole point of it. lib := &fakeLibrary{chapters: pgstore.ChapterPage{Chapters: rows}, stream: pgstore.StreamState{AtRest: true}} h := readingServer(t, lib) r := httptest.NewRequest("GET", "/v0/books/bk_1/chapters", nil) r.Header.Set("Authorization", "Bearer token") r.Header.Set("Accept-Encoding", "gzip") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Header().Get("Content-Encoding") != "gzip" { t.Errorf("a large JSON answer was not compressed: %d bytes", w.Body.Len()) } if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { t.Error("a negotiated representation carries no Vary") } sr := httptest.NewRequest("GET", "/v0/books/bk_1/events", nil) sr.Header.Set("Authorization", "Bearer token") sr.Header.Set("Accept-Encoding", "gzip") sw := httptest.NewRecorder() h.ServeHTTP(sw, sr) if enc := sw.Header().Get("Content-Encoding"); enc == "gzip" { t.Error("the event stream was compressed, which buffers it") } } // The page size is never a reason to refuse: over the maximum it is CLAMPED, and anything that is // not a page size at all falls back to the deployment's default. Answering the default instead of // clamping is what made "ask for more, get fewer rows than a smaller request" discoverable only by // experiment; refusing is what the canon exempts this parameter from. func TestALimitIsClampedOrIgnoredButNeverRefused(t *testing.T) { lib := &fakeLibrary{} h := readingServer(t, lib) // ⚠ What this observes is the limit the handler PASSED DOWN — asserting only the status left the // parameter unobserved, since the fake discarded it. The handler's own duty is to refuse nothing // and to turn what is not a page size into "the deployment's default" (0); the CLAMP itself // belongs to the store, and is pinned there (pgstore.TestAPageSizeIsClampedAndNeverRefused). for _, tc := range []struct { query string want int }{ {"limit=10", 10}, {"limit=100000", 100000}, {"limit=0", 0}, {"limit=-3", 0}, {"limit=abc", 0}, {"limit=", 0}, } { w := call(t, h, "GET", "/v0/books/bk_1/chapters?"+tc.query, "") if w.Code != http.StatusOK { t.Errorf("%s answered %d: %s", tc.query, w.Code, w.Body) continue } if lib.limit != tc.want { t.Errorf("%s reached the store as %d, want %d", tc.query, lib.limit, tc.want) } } } // A watermark from before a wholesale replacement cannot be answered with a delta, and the refusal // names the remedy in its cause. func TestAStaleWatermarkIsRefusedWithItsCause(t *testing.T) { lib := &fakeLibrary{err: pgstore.ErrVersionTooOld} w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/notes?after_version=3", "") if w.Code != http.StatusBadRequest { t.Fatalf("status %d", w.Code) } got := decode(t, w) cause, _ := got["cause"].(map[string]any) if got["code"] != "invalid_request" || cause["code"] != "version_too_old" { t.Errorf("problem: %v", got) } } // A chapter that existed and no longer does is GONE, not absent: the remedy differs — the client // re-reads the tree rather than checking the address. func TestAChapterOutsideTheCurrentCutIsGone(t *testing.T) { lib := &fakeLibrary{err: pgstore.ErrNoChapter} w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/chapters/ch_old/units", "") if w.Code != http.StatusGone { t.Fatalf("status %d, want 410", w.Code) } if got := decode(t, w); got["code"] != "gone" { t.Errorf("code %v", got["code"]) } }