307 lines
13 KiB
Go
307 lines
13 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".
|
|
//
|
|
// The aggregate pair is `total`/`signed` and NOTHING ELSE: canon 0.5.0 removed the per-term-model
|
|
// counters `pending_decisions`/`complete` (PD-399), which had degraded into a live count of
|
|
// `proposed` rows — so their ABSENCE is pinned on the first page too, where they used to ride.
|
|
func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) {
|
|
full := pgstore.BankPage{
|
|
Page: pgstore.Page{Revision: 1841, StructureVersion: 3},
|
|
First: true,
|
|
Counts: pgstore.BankCounts{Total: 300, Signed: 40},
|
|
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", "structure_version"} {
|
|
if _, ok := got[k]; !ok {
|
|
t.Errorf("the first page is missing %q", k)
|
|
}
|
|
}
|
|
for _, k := range []string{"pending_decisions", "complete"} {
|
|
if _, ok := got[k]; ok {
|
|
t.Errorf("the removed counter %q is still on the wire (canon 0.5.0, PD-399)", 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"} {
|
|
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 seven fields are what a client is entitled to decide from before it spends anything
|
|
// (0.5.0 added `bank_corrections_enabled`: the correction door is declared ahead of its serving
|
|
// half, and a client must learn a deployment has not mounted it here, not by failing a save).
|
|
func TestCapabilitiesCarriesTheSevenFacts(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", "bank_corrections_enabled", "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"])
|
|
}
|
|
}
|