| 1 | package suite |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | // assertResults is the guard the first version of these tests lacked. Every |
| 9 | // case here is about totality, and a decoder that returns nothing satisfies a |
| 10 | // loop over its results without asserting anything at all — mutating the |
| 11 | // non-2xx branch to `return nil` passed the whole suite. Length first, always. |
| 12 | func assertResults(t *testing.T, got []Authorization, want []Authorization) { |
| 13 | t.Helper() |
| 14 | if len(got) != len(want) { |
| 15 | t.Fatalf("got %d results, want exactly %d — the decoder is total or it is nothing", len(got), len(want)) |
| 16 | } |
| 17 | for i := range want { |
| 18 | if got[i] != want[i] { |
| 19 | t.Errorf("result %d: got %q, want %q", i, got[i], want[i]) |
| 20 | } |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // The four values, each returned distinctly, on pairs that differ by id and by |
| 25 | // revision. Earlier versions used two ids at one revision each and never a |
| 26 | // STALE, so the decoder could ignore Revision entirely — or drop STALE from |
| 27 | // its vocabulary — and the suite still passed. |
| 28 | func TestS2DecodeAllFourValues(t *testing.T) { |
| 29 | asked := []ItemRevision{ |
| 30 | {ItemID: "doc-1", Revision: 7}, |
| 31 | {ItemID: "doc-2", Revision: 3}, |
| 32 | {ItemID: "doc-3", Revision: 11}, |
| 33 | {ItemID: "doc-4", Revision: 2}, |
| 34 | } |
| 35 | body := []byte(`{"results":[ |
| 36 | {"item_id":"doc-3","revision":11,"result":"STALE"}, |
| 37 | {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| 38 | {"item_id":"doc-4","revision":2,"result":"UNKNOWN"}, |
| 39 | {"item_id":"doc-2","revision":3,"result":"NOT_VISIBLE"} |
| 40 | ]}`) |
| 41 | |
| 42 | got := DecodeS2(asked, 200, body) |
| 43 | |
| 44 | // Answered out of order on purpose: results come back in the order asked, |
| 45 | // not the order the sibling felt like replying in. |
| 46 | assertResults(t, got, []Authorization{VISIBLE, NOT_VISIBLE, STALE, UNKNOWN}) |
| 47 | } |
| 48 | |
| 49 | // The revision is part of the identity of a pair. An answer about revision 6 |
| 50 | // does not answer a question about revision 7 of the same item — that is the |
| 51 | // whole reason STALE exists — so it must not be matched to it. |
| 52 | func TestS2AnswerForAnotherRevisionDoesNotMatch(t *testing.T) { |
| 53 | asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| 54 | body := []byte(`{"results":[{"item_id":"doc-1","revision":6,"result":"VISIBLE"}]}`) |
| 55 | |
| 56 | got := DecodeS2(asked, 200, body) |
| 57 | |
| 58 | assertResults(t, got, []Authorization{UNKNOWN}) |
| 59 | } |
| 60 | |
| 61 | // The same item at two revisions is two questions, and each gets its own |
| 62 | // answer. |
| 63 | func TestS2SameItemAtTwoRevisions(t *testing.T) { |
| 64 | asked := []ItemRevision{ |
| 65 | {ItemID: "doc-1", Revision: 6}, |
| 66 | {ItemID: "doc-1", Revision: 7}, |
| 67 | } |
| 68 | body := []byte(`{"results":[ |
| 69 | {"item_id":"doc-1","revision":6,"result":"STALE"}, |
| 70 | {"item_id":"doc-1","revision":7,"result":"VISIBLE"} |
| 71 | ]}`) |
| 72 | |
| 73 | got := DecodeS2(asked, 200, body) |
| 74 | |
| 75 | assertResults(t, got, []Authorization{STALE, VISIBLE}) |
| 76 | } |
| 77 | |
| 78 | // An item the app of record did not answer for is UNKNOWN — never the zero |
| 79 | // value, and never NOT_VISIBLE. memoria §4.1: a non-answer is synthesised by |
| 80 | // the caller, because a sibling that never answers cannot return anything. |
| 81 | func TestS2MissingItemIsUnknown(t *testing.T) { |
| 82 | asked := []ItemRevision{ |
| 83 | {ItemID: "doc-1", Revision: 7}, |
| 84 | {ItemID: "doc-2", Revision: 3}, |
| 85 | } |
| 86 | body := []byte(`{"results":[{"item_id":"doc-1","revision":7,"result":"VISIBLE"}]}`) |
| 87 | |
| 88 | got := DecodeS2(asked, 200, body) |
| 89 | |
| 90 | assertResults(t, got, []Authorization{VISIBLE, UNKNOWN}) |
| 91 | } |
| 92 | |
| 93 | // An answer nobody asked for is ignored rather than shifting the others along. |
| 94 | func TestS2UnsolicitedAnswerIsIgnored(t *testing.T) { |
| 95 | asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| 96 | body := []byte(`{"results":[ |
| 97 | {"item_id":"doc-9","revision":1,"result":"VISIBLE"}, |
| 98 | {"item_id":"doc-1","revision":7,"result":"NOT_VISIBLE"} |
| 99 | ]}`) |
| 100 | |
| 101 | got := DecodeS2(asked, 200, body) |
| 102 | |
| 103 | assertResults(t, got, []Authorization{NOT_VISIBLE}) |
| 104 | } |
| 105 | |
| 106 | // A non-2xx status is a whole-call failure: every pair is UNKNOWN, whatever |
| 107 | // the body happens to contain. A body arriving with a 500 is not an answer. |
| 108 | func TestS2NonSuccessStatusIsWholeCallUnknown(t *testing.T) { |
| 109 | asked := []ItemRevision{ |
| 110 | {ItemID: "doc-1", Revision: 7}, |
| 111 | {ItemID: "doc-2", Revision: 3}, |
| 112 | } |
| 113 | body := []byte(`{"results":[ |
| 114 | {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| 115 | {"item_id":"doc-2","revision":3,"result":"VISIBLE"} |
| 116 | ]}`) |
| 117 | |
| 118 | got := DecodeS2(asked, 500, body) |
| 119 | |
| 120 | assertResults(t, got, []Authorization{UNKNOWN, UNKNOWN}) |
| 121 | } |
| 122 | |
| 123 | // A body that does not parse is a whole-call failure too. |
| 124 | func TestS2MalformedBodyIsWholeCallUnknown(t *testing.T) { |
| 125 | asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| 126 | |
| 127 | got := DecodeS2(asked, 200, []byte(`{"results":[{"item_id":`)) |
| 128 | |
| 129 | assertResults(t, got, []Authorization{UNKNOWN}) |
| 130 | } |
| 131 | |
| 132 | // An item answered twice is UNKNOWN for that item. Two answers are not an |
| 133 | // answer, and picking either one would be the caller inventing a decision the |
| 134 | // app of record did not make. The other pairs are unaffected. |
| 135 | func TestS2DuplicateRowIsUnknown(t *testing.T) { |
| 136 | asked := []ItemRevision{ |
| 137 | {ItemID: "doc-1", Revision: 7}, |
| 138 | {ItemID: "doc-2", Revision: 3}, |
| 139 | } |
| 140 | body := []byte(`{"results":[ |
| 141 | {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| 142 | {"item_id":"doc-1","revision":7,"result":"NOT_VISIBLE"}, |
| 143 | {"item_id":"doc-2","revision":3,"result":"VISIBLE"} |
| 144 | ]}`) |
| 145 | |
| 146 | got := DecodeS2(asked, 200, body) |
| 147 | |
| 148 | assertResults(t, got, []Authorization{UNKNOWN, VISIBLE}) |
| 149 | } |
| 150 | |
| 151 | // A duplicated *key* is the same defect one level down, and encoding/json |
| 152 | // hides it: it keeps the last value silently, so the duplicate-row check above |
| 153 | // never runs. Reported by the GPT-6 module review, finding 2. |
| 154 | func TestS2DuplicateKeyInARowIsRefused(t *testing.T) { |
| 155 | asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| 156 | body := []byte(`{"results":[ |
| 157 | {"item_id":"doc-1","revision":7,"result":"NOT_VISIBLE","result":"VISIBLE"} |
| 158 | ]}`) |
| 159 | |
| 160 | got := DecodeS2(asked, 200, body) |
| 161 | |
| 162 | assertResults(t, got, []Authorization{UNKNOWN}) |
| 163 | } |
| 164 | |
| 165 | // The same trick at the envelope level: two `results` arrays, the second |
| 166 | // overwriting the first. |
| 167 | func TestS2DuplicateEnvelopeKeyIsRefused(t *testing.T) { |
| 168 | asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| 169 | body := []byte(`{ |
| 170 | "results":[{"item_id":"doc-1","revision":7,"result":"NOT_VISIBLE"}], |
| 171 | "results":[{"item_id":"doc-1","revision":7,"result":"VISIBLE"}] |
| 172 | }`) |
| 173 | |
| 174 | got := DecodeS2(asked, 200, body) |
| 175 | |
| 176 | assertResults(t, got, []Authorization{UNKNOWN}) |
| 177 | } |
| 178 | |
| 179 | // Trailing content after a complete document is refused. A sibling appending |
| 180 | // a second object is not speaking this contract. |
| 181 | func TestS2TrailingContentIsRefused(t *testing.T) { |
| 182 | asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| 183 | body := []byte(`{"results":[{"item_id":"doc-1","revision":7,"result":"VISIBLE"}]} {"results":[]}`) |
| 184 | |
| 185 | got := DecodeS2(asked, 200, body) |
| 186 | |
| 187 | assertResults(t, got, []Authorization{UNKNOWN}) |
| 188 | } |
| 189 | |
| 190 | // A value outside the four is UNKNOWN. The vocabulary is closed (§4.3), so an |
| 191 | // unrecognised word is a sibling this client does not understand, not a fifth |
| 192 | // meaning to pass through to a caller that will switch on it. |
| 193 | func TestS2UnrecognisedValueIsUnknown(t *testing.T) { |
| 194 | asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| 195 | body := []byte(`{"results":[{"item_id":"doc-1","revision":7,"result":"MAYBE"}]}`) |
| 196 | |
| 197 | got := DecodeS2(asked, 200, body) |
| 198 | |
| 199 | assertResults(t, got, []Authorization{UNKNOWN}) |
| 200 | } |
| 201 | |
| 202 | // A full-size question decodes one result per pair and nothing else. 256 is |
| 203 | // written literally rather than derived from MaxS2Pairs: a test that computes |
| 204 | // both sides from the constant checks its own consistency, and changing the |
| 205 | // constant to 512 passed the entire suite. |
| 206 | func TestS2DecodesAFullBatch(t *testing.T) { |
| 207 | const fullBatch = 256 |
| 208 | |
| 209 | asked := make([]ItemRevision, fullBatch) |
| 210 | want := make([]Authorization, fullBatch) |
| 211 | body := `{"results":[` |
| 212 | for i := range asked { |
| 213 | asked[i] = ItemRevision{ItemID: fmt.Sprintf("doc-%d", i), Revision: int64(i)} |
| 214 | want[i] = VISIBLE |
| 215 | if i > 0 { |
| 216 | body += "," |
| 217 | } |
| 218 | body += fmt.Sprintf(`{"item_id":"doc-%d","revision":%d,"result":"VISIBLE"}`, i, i) |
| 219 | } |
| 220 | body += `]}` |
| 221 | |
| 222 | got := DecodeS2(asked, 200, []byte(body)) |
| 223 | |
| 224 | assertResults(t, got, want) |
| 225 | } |
| 226 | |