textmachine/platform/internal/httpapi/reading_test.go

296 lines
12 KiB
Go

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"])
}
}