| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/docs/internal/core" |
| 11 | ) |
| 12 | |
| 13 | // newSampleDocumentsInternalTestApp is this file's own copy of |
| 14 | // sample_documents_test.go's newSampleDocumentsTestApp: that one |
| 15 | // lives in package docs_test and cannot be reused from here, and the |
| 16 | // tests below need package-internal access (a *Store's own db handle, |
| 17 | // to soft-delete rows the way a future "delete document" handler |
| 18 | // would; a second, deliberately broken *core.Runtime) that only a |
| 19 | // docs-package test can reach. |
| 20 | func newSampleDocumentsInternalTestApp(t *testing.T, sampleDocuments string) *App { |
| 21 | t.Helper() |
| 22 | app, err := New(Config{ |
| 23 | DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| 24 | Env: "development", |
| 25 | SampleDocuments: sampleDocuments, |
| 26 | }) |
| 27 | if err != nil { |
| 28 | t.Fatalf("New: %v", err) |
| 29 | } |
| 30 | t.Cleanup(func() { |
| 31 | if cerr := app.Close(); cerr != nil { |
| 32 | t.Errorf("App.Close: %v", cerr) |
| 33 | } |
| 34 | }) |
| 35 | return app |
| 36 | } |
| 37 | |
| 38 | // TestPersonWhoDeletesSampleDocumentsGetsNoneBackOnNextSignIn is the |
| 39 | // property EnsurePerson's own doc comment calls out by name: seeding |
| 40 | // is tied to "this Person row was just created", never to "this |
| 41 | // person's list happens to be empty". There is no delete handler yet |
| 42 | // (spec's later work), so this reaches into store.db directly to |
| 43 | // soft-delete every one of the seeded rows exactly the way such a |
| 44 | // handler would (setting deleted_at, the same column List's own |
| 45 | // "deleted_at IS NULL" filter checks) — then signs the same address |
| 46 | // in again and asserts nothing comes back. |
| 47 | func TestPersonWhoDeletesSampleDocumentsGetsNoneBackOnNextSignIn(t *testing.T) { |
| 48 | app := newSampleDocumentsInternalTestApp(t, "1") |
| 49 | ctx := context.Background() |
| 50 | store := app.Store() |
| 51 | |
| 52 | owner, err := store.EnsurePerson(ctx, "deletes-everything@example.com") |
| 53 | if err != nil { |
| 54 | t.Fatalf("EnsurePerson (first): %v", err) |
| 55 | } |
| 56 | first, err := store.List(ctx, owner) |
| 57 | if err != nil { |
| 58 | t.Fatalf("List (first): %v", err) |
| 59 | } |
| 60 | if len(first) != 6 { |
| 61 | t.Fatalf("got %d seeded documents, want 6", len(first)) |
| 62 | } |
| 63 | |
| 64 | now := time.Now().UTC() |
| 65 | if err := owned(store.db.WithContext(ctx), owner). |
| 66 | Model(&Document{}). |
| 67 | Update("deleted_at", now).Error; err != nil { |
| 68 | t.Fatalf("soft-delete owner's documents: %v", err) |
| 69 | } |
| 70 | |
| 71 | afterDelete, err := store.List(ctx, owner) |
| 72 | if err != nil { |
| 73 | t.Fatalf("List (after delete): %v", err) |
| 74 | } |
| 75 | if len(afterDelete) != 0 { |
| 76 | t.Fatalf("got %d documents after deleting all of them, want 0", len(afterDelete)) |
| 77 | } |
| 78 | |
| 79 | // The person row already exists, so this hits EnsurePerson's |
| 80 | // found branch, not its create branch — seedIfEnabled is never |
| 81 | // called, and List must stay at zero. |
| 82 | ownerAgain, err := store.EnsurePerson(ctx, "deletes-everything@example.com") |
| 83 | if err != nil { |
| 84 | t.Fatalf("EnsurePerson (second): %v", err) |
| 85 | } |
| 86 | if ownerAgain != owner { |
| 87 | t.Fatalf("EnsurePerson(again) = %d, want %d", ownerAgain, owner) |
| 88 | } |
| 89 | |
| 90 | after, err := store.List(ctx, owner) |
| 91 | if err != nil { |
| 92 | t.Fatalf("List (after second sign-in): %v", err) |
| 93 | } |
| 94 | if len(after) != 0 { |
| 95 | t.Fatalf("got %d documents back after signing in again, want 0 — sample documents were resurrected", len(after)) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // TestEnsurePersonSurvivesSeedingFailure is the care point the task |
| 100 | // calls out explicitly: seeding failure must never break sign-in. To |
| 101 | // force Seed itself to fail without touching the Person row |
| 102 | // EnsurePerson creates first (which would defeat the point of the |
| 103 | // test — the person must exist BEFORE seeding is even attempted), |
| 104 | // this builds a second, deliberately broken *core.Runtime — closed |
| 105 | // immediately after it compiles — and hands it to a Store sharing the |
| 106 | // real, working database underneath app's own Store. |
| 107 | // |
| 108 | // Confirmed directly (see the probe this test's design was checked |
| 109 | // against): calling a wazero module method through a Runtime whose |
| 110 | // underlying wazero runtime has been Close'd returns a clean error |
| 111 | // ("runtime closed with exit_code(0)"), not a panic — Store.replay's |
| 112 | // s.core.NewDoc call, the first thing Seed's loop does for its first |
| 113 | // document's ApplyUpdate, fails this way every time, deterministically, |
| 114 | // with no flakiness from timing. |
| 115 | func TestEnsurePersonSurvivesSeedingFailure(t *testing.T) { |
| 116 | app := newSampleDocumentsInternalTestApp(t, "1") |
| 117 | ctx := context.Background() |
| 118 | |
| 119 | brokenCore, err := core.New(ctx) |
| 120 | if err != nil { |
| 121 | t.Fatalf("core.New: %v", err) |
| 122 | } |
| 123 | if err := brokenCore.Close(ctx); err != nil { |
| 124 | t.Fatalf("brokenCore.Close: %v", err) |
| 125 | } |
| 126 | |
| 127 | broken := NewStore(app.store.db, brokenCore, true, app.logger) |
| 128 | |
| 129 | id, err := broken.EnsurePerson(ctx, "seed-fails@example.com") |
| 130 | if err != nil { |
| 131 | t.Fatalf("EnsurePerson must succeed even when seeding fails, got: %v", err) |
| 132 | } |
| 133 | if id <= 0 { |
| 134 | t.Fatalf("EnsurePerson returned a non-positive id: %d", id) |
| 135 | } |
| 136 | |
| 137 | // The person is durably there: a fresh EnsurePerson call for the |
| 138 | // same address finds the same row rather than creating (or |
| 139 | // re-seeding) it. |
| 140 | id2, err := broken.EnsurePerson(ctx, "seed-fails@example.com") |
| 141 | if err != nil { |
| 142 | t.Fatalf("EnsurePerson (again): %v", err) |
| 143 | } |
| 144 | if id2 != id { |
| 145 | t.Fatalf("EnsurePerson(again) = %d, want %d (same person, not recreated)", id2, id) |
| 146 | } |
| 147 | |
| 148 | // And the same person, looked up through the app's own working |
| 149 | // Store, is a real, usable person: List works and returns without |
| 150 | // error (whatever partial content Seed managed before it failed — |
| 151 | // this asserts "does not break", not "produced zero documents", |
| 152 | // since Seed's per-document loop may have left one partial row |
| 153 | // behind before its first ApplyUpdate failed). |
| 154 | docs, err := app.Store().List(ctx, id) |
| 155 | if err != nil { |
| 156 | t.Fatalf("List for the person seeding failed for: %v", err) |
| 157 | } |
| 158 | |
| 159 | // Assert the seeding actually FAILED. Without this the test is |
| 160 | // vacuous: "EnsurePerson succeeded and List did not error" holds |
| 161 | // just as well if seeding silently succeeded, or was skipped |
| 162 | // because a future refactor stopped calling seedIfEnabled at all |
| 163 | // — in which case this test would keep passing while proving |
| 164 | // nothing about surviving a failure. A complete seed is |
| 165 | // len(seedDocs) documents, so anything short of that is |
| 166 | // positive evidence the broken core was reached and the loop died |
| 167 | // partway. Deliberately not asserting an exact count: where |
| 168 | // precisely Seed dies is an implementation detail, but that it |
| 169 | // died at all is the whole property under test. |
| 170 | if len(docs) >= len(seedDocs) { |
| 171 | t.Fatalf("got %d documents, want fewer than %d — seeding was expected to FAIL against the closed core, so this test is not proving what it claims", len(docs), len(seedDocs)) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // TestConcurrentFirstSignInsProduceOnePersonAndOneSampleSet is the |
| 176 | // concurrency requirement stated explicitly: two simultaneous first |
| 177 | // sign-ins for the same brand-new address must not produce two |
| 178 | // people or two sets of documents. EnsurePerson's own duplicate-key |
| 179 | // race handling (store.go) already guarantees one Person row; this |
| 180 | // pins that seeding rides along with it — exactly one winner reaches |
| 181 | // seedIfEnabled, so the address ends up with exactly six documents, |
| 182 | // never twelve. |
| 183 | func TestConcurrentFirstSignInsProduceOnePersonAndOneSampleSet(t *testing.T) { |
| 184 | app := newSampleDocumentsInternalTestApp(t, "1") |
| 185 | store := app.Store() |
| 186 | const address = "concurrent-first-signin@example.com" |
| 187 | |
| 188 | const n = 8 |
| 189 | ids := make([]int64, n) |
| 190 | errs := make([]error, n) |
| 191 | var wg sync.WaitGroup |
| 192 | wg.Add(n) |
| 193 | for i := 0; i < n; i++ { |
| 194 | go func(i int) { |
| 195 | defer wg.Done() |
| 196 | ids[i], errs[i] = store.EnsurePerson(context.Background(), address) |
| 197 | }(i) |
| 198 | } |
| 199 | wg.Wait() |
| 200 | |
| 201 | for i, err := range errs { |
| 202 | if err != nil { |
| 203 | t.Fatalf("EnsurePerson[%d]: %v", i, err) |
| 204 | } |
| 205 | } |
| 206 | for i := 1; i < n; i++ { |
| 207 | if ids[i] != ids[0] { |
| 208 | t.Fatalf("EnsurePerson returned different ids across concurrent calls: %d and %d — two people were created for one address", ids[0], ids[i]) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | list, err := store.List(context.Background(), ids[0]) |
| 213 | if err != nil { |
| 214 | t.Fatalf("List: %v", err) |
| 215 | } |
| 216 | if len(list) != 6 { |
| 217 | t.Fatalf("got %d documents after %d concurrent first sign-ins, want exactly 6 (one seeded set, not %d)", len(list), n, n*6) |
| 218 | } |
| 219 | } |
| 220 | |