| 1 | package suite |
| 2 | |
| 3 | import "testing" |
| 4 | |
| 5 | // S1 rows are (item_id, revision, gone, touched_at) and nothing else |
| 6 | // (doctrine §6). Draft 1 of memoria's feed carried a visible-to-team field and |
| 7 | // that was a leak: `visible=false, gone=false` tells a consumer that a |
| 8 | // guest-only item exists, at what revision and when it was touched — exactly |
| 9 | // what S3's "404, never 403" exists to hide. |
| 10 | func TestS1DecodeRows(t *testing.T) { |
| 11 | body := []byte(`{"cursor":"c-92","rows":[ |
| 12 | {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z"}, |
| 13 | {"item_id":"doc-2","revision":2,"gone":true,"touched_at":"2026-09-05T09:01:00Z"} |
| 14 | ]}`) |
| 15 | |
| 16 | feed, err := DecodeS1(200, body) |
| 17 | if err != nil { |
| 18 | t.Fatalf("unexpected error: %v", err) |
| 19 | } |
| 20 | |
| 21 | if feed.Cursor != "c-92" { |
| 22 | t.Errorf("cursor: got %q, want %q", feed.Cursor, "c-92") |
| 23 | } |
| 24 | if len(feed.Rows) != 2 { |
| 25 | t.Fatalf("got %d rows, want 2", len(feed.Rows)) |
| 26 | } |
| 27 | if feed.Rows[0].ItemID != "doc-1" || feed.Rows[0].Revision != 7 || feed.Rows[0].Gone { |
| 28 | t.Errorf("row 0: got %+v", feed.Rows[0]) |
| 29 | } |
| 30 | if !feed.Rows[1].Gone { |
| 31 | t.Errorf("row 1: got gone=false, want the tombstone") |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | // A feed row carrying anything beyond the four fields is refused, loudly. The |
| 36 | // contract says a row has no title, no content and no visibility flag; a |
| 37 | // sibling sending one is either leaking or running a different contract, and |
| 38 | // silently ignoring the field would let either go unnoticed for as long as |
| 39 | // nobody grepped the store. |
| 40 | func TestS1RejectsRowCarryingContent(t *testing.T) { |
| 41 | body := []byte(`{"cursor":"c-92","rows":[ |
| 42 | {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z","title":"Q3 plan"} |
| 43 | ]}`) |
| 44 | |
| 45 | _, err := DecodeS1(200, body) |
| 46 | |
| 47 | if err == nil { |
| 48 | t.Fatal("got nil error for a row carrying a title, want a refusal") |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Departure from team-visibility and deletion are the same row, and the |
| 53 | // client must not offer a caller any way to tell them apart (§4.2). |
| 54 | func TestS1GoneCarriesNoReason(t *testing.T) { |
| 55 | body := []byte(`{"cursor":"c-1","rows":[ |
| 56 | {"item_id":"doc-1","revision":7,"gone":true,"touched_at":"2026-09-05T09:00:00Z","reason":"went_private"} |
| 57 | ]}`) |
| 58 | |
| 59 | _, err := DecodeS1(200, body) |
| 60 | |
| 61 | if err == nil { |
| 62 | t.Fatal("got nil error for a gone row carrying a reason, want a refusal") |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // An empty feed is legitimate and must keep working: it means nothing changed. |
| 67 | func TestS1EmptyFeedIsValid(t *testing.T) { |
| 68 | feed, err := DecodeS1(200, []byte(`{"cursor":"c-1","rows":[]}`)) |
| 69 | if err != nil { |
| 70 | t.Fatalf("unexpected error: %v", err) |
| 71 | } |
| 72 | if feed.Cursor != "c-1" || len(feed.Rows) != 0 { |
| 73 | t.Errorf("got %+v, want an empty feed at cursor c-1", feed) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // Bodies that parse but say nothing. `null` and `{}` both decode without |
| 78 | // error into a zero Feed, which a caller would read as an empty feed at an |
| 79 | // empty cursor — and committing that cursor loses the consumer's place. |
| 80 | // Reported by the GPT-6 module review, finding 8. |
| 81 | func TestS1RejectsBodiesThatSayNothing(t *testing.T) { |
| 82 | for _, body := range []string{`null`, `{}`, `{"rows":[]}`} { |
| 83 | if _, err := DecodeS1(200, []byte(body)); err == nil { |
| 84 | t.Errorf("accepted %s, want a refusal", body) |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // A row missing its identity, or carrying null where a value belongs, decodes |
| 90 | // to a live zero-value row: item "" at revision 0, present rather than gone. |
| 91 | func TestS1RejectsIncompleteRows(t *testing.T) { |
| 92 | bodies := []string{ |
| 93 | `{"cursor":"c-1","rows":[{}]}`, |
| 94 | `{"cursor":"c-1","rows":[{"item_id":"","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z"}]}`, |
| 95 | `{"cursor":"c-1","rows":[{"item_id":"doc-1","revision":7,"gone":null,"touched_at":null}]}`, |
| 96 | } |
| 97 | for _, body := range bodies { |
| 98 | if _, err := DecodeS1(200, []byte(body)); err == nil { |
| 99 | t.Errorf("accepted %s, want a refusal", body) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // Trailing content after a complete page, and duplicate keys inside a row. |
| 105 | // json.Decoder.Decode reads one value and stops, so a second document — one |
| 106 | // carrying a title, say — rides along unread; and a duplicated `gone` key is |
| 107 | // resolved silently, so `gone:true,gone:false` loses a tombstone. |
| 108 | func TestS1RejectsAmbiguousBodies(t *testing.T) { |
| 109 | bodies := []string{ |
| 110 | `{"cursor":"c-1","rows":[]} {"cursor":"c-2","rows":[{"title":"Q3 plan"}]}`, |
| 111 | `{"cursor":"c-1","rows":[{"item_id":"doc-1","revision":7,"gone":true,"gone":false,"touched_at":"2026-09-05T09:00:00Z"}]}`, |
| 112 | `{"cursor":"c-1","cursor":"c-2","rows":[]}`, |
| 113 | } |
| 114 | for _, body := range bodies { |
| 115 | if _, err := DecodeS1(200, []byte(body)); err == nil { |
| 116 | t.Errorf("accepted %s, want a refusal", body) |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // A non-2xx feed answer is an error, not an empty feed. An empty feed means |
| 122 | // "nothing changed"; returning that for a failed call would advance a |
| 123 | // consumer's cursor past changes it never saw. |
| 124 | func TestS1NonSuccessIsAnError(t *testing.T) { |
| 125 | _, err := DecodeS1(503, []byte(`{"cursor":"c-1","rows":[]}`)) |
| 126 | |
| 127 | if err == nil { |
| 128 | t.Fatal("got nil error for a 503, want a refusal") |
| 129 | } |
| 130 | } |
| 131 | |