返回 DeepSeek-Reasonix
store_test.go
根目录 / desktop / internal / draftstate / store_test.go
1 package draftstate
2
3 import (
4 "bytes"
5 "context"
6 "database/sql"
7 "errors"
8 "os"
9 "path/filepath"
10 "sync"
11 "testing"
12
13 _ "modernc.org/sqlite"
14 )
15
16 func testStore(t *testing.T) *Store {
17 t.Helper()
18 store := New(filepath.Join(t.TempDir(), "drafts.sqlite"))
19 t.Cleanup(func() { _ = store.Close() })
20 return store
21 }
22
23 func TestDraftPersistsAndRestoresAcrossStoreRestart(t *testing.T) {
24 ctx := context.Background()
25 path := filepath.Join(t.TempDir(), "drafts.sqlite")
26 first := New(path)
27 draft, created, err := first.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{"model":"a"}`)
28 if err != nil || !created {
29 t.Fatalf("Open() = created %v, err %v", created, err)
30 }
31 saved, err := first.Save(ctx, draft.ID, draft.Revision, `{"text":"hello"}`, `{"model":"b"}`, false)
32 if err != nil {
33 t.Fatalf("Save() error = %v", err)
34 }
35 if err := first.SetRestore(ctx, draft.ID); err != nil {
36 t.Fatalf("SetRestore() error = %v", err)
37 }
38 if err := first.Close(); err != nil {
39 t.Fatalf("Close() error = %v", err)
40 }
41
42 second := New(path)
43 t.Cleanup(func() { _ = second.Close() })
44 restored, err := second.Restore(ctx)
45 if err != nil {
46 t.Fatalf("Restore() error = %v", err)
47 }
48 if restored.ID != draft.ID || restored.Revision != saved.Revision || restored.ContentJSON != `{"text":"hello"}` {
49 t.Fatalf("restored = %+v, want draft %q revision %d", restored, draft.ID, saved.Revision)
50 }
51 }
52
53 func TestOpenReusesOneActiveDraftPerWorkspaceAcrossStores(t *testing.T) {
54 ctx := context.Background()
55 path := filepath.Join(t.TempDir(), "drafts.sqlite")
56 stores := []*Store{New(path), New(path)}
57 for _, store := range stores {
58 defer store.Close()
59 }
60 var wg sync.WaitGroup
61 results := make(chan Draft, len(stores))
62 errs := make(chan error, len(stores))
63 for index, store := range stores {
64 wg.Add(1)
65 go func(index int, store *Store) {
66 defer wg.Done()
67 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-"+string(rune('a'+index)), `{}`)
68 if err != nil {
69 errs <- err
70 return
71 }
72 results <- draft
73 }(index, store)
74 }
75 wg.Wait()
76 close(results)
77 close(errs)
78 for err := range errs {
79 if err != nil {
80 t.Fatalf("concurrent Open() error = %v", err)
81 }
82 }
83 var id string
84 for draft := range results {
85 if id == "" {
86 id = draft.ID
87 }
88 if draft.ID != id {
89 t.Fatalf("concurrent drafts = %q and %q", id, draft.ID)
90 }
91 }
92 }
93
94 func TestSaveConflictPreservesConflictCopy(t *testing.T) {
95 ctx := context.Background()
96 store := testStore(t)
97 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
98 if err != nil {
99 t.Fatal(err)
100 }
101 saved, err := store.Save(ctx, draft.ID, draft.Revision, `{"text":"saved"}`, `{}`, false)
102 if err != nil {
103 t.Fatal(err)
104 }
105 current, err := store.Save(ctx, draft.ID, draft.Revision, `{"text":"local"}`, `{}`, false)
106 if !errors.Is(err, ErrConflict) {
107 t.Fatalf("Save() error = %v, want conflict", err)
108 }
109 if current.Revision != saved.Revision || current.ContentJSON != saved.ContentJSON {
110 t.Fatalf("conflict current = %+v", current)
111 }
112 var count int
113 if err := store.withDB(ctx, func(db *sql.DB) error {
114 return db.QueryRowContext(ctx, `SELECT COUNT(*) FROM conflicts WHERE draft_id=? AND content_json=?`, draft.ID, `{"text":"local"}`).Scan(&count)
115 }); err != nil {
116 t.Fatal(err)
117 }
118 if count != 1 {
119 t.Fatalf("conflict copies = %d, want 1", count)
120 }
121 }
122
123 func TestForceSaveStillUsesRevisionCAS(t *testing.T) {
124 ctx := context.Background()
125 store := testStore(t)
126 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
127 if err != nil {
128 t.Fatal(err)
129 }
130 saved, err := store.Save(ctx, draft.ID, draft.Revision, `{"text":"other window"}`, `{}`, false)
131 if err != nil {
132 t.Fatal(err)
133 }
134 current, err := store.Save(ctx, draft.ID, draft.Revision, `{"text":"local"}`, `{}`, true)
135 if !errors.Is(err, ErrConflict) {
136 t.Fatalf("force Save() error = %v, want conflict", err)
137 }
138 if current.Revision != saved.Revision || current.ContentJSON != saved.ContentJSON {
139 t.Fatalf("force save overwrote unseen revision: %+v", current)
140 }
141 }
142
143 func TestOpenDoesNotChangeRestoreTarget(t *testing.T) {
144 ctx := context.Background()
145 store := testStore(t)
146 first, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
147 if err != nil {
148 t.Fatal(err)
149 }
150 if err := store.SetRestore(ctx, first.ID); err != nil {
151 t.Fatal(err)
152 }
153 if _, _, err := store.Open(ctx, "workspace-b", "project", "/workspace/b", "draft-b", `{}`); err != nil {
154 t.Fatal(err)
155 }
156 restored, err := store.Restore(ctx)
157 if err != nil {
158 t.Fatal(err)
159 }
160 if restored.ID != first.ID {
161 t.Fatalf("restore target = %q, want %q", restored.ID, first.ID)
162 }
163 }
164
165 func TestUnknownSchemaVersionRefusesWrites(t *testing.T) {
166 path := filepath.Join(t.TempDir(), "drafts.sqlite")
167 db, err := sql.Open("sqlite", path)
168 if err != nil {
169 t.Fatal(err)
170 }
171 if _, err := db.Exec(`PRAGMA user_version = 99`); err != nil {
172 t.Fatal(err)
173 }
174 if err := db.Close(); err != nil {
175 t.Fatal(err)
176 }
177 before, err := os.ReadFile(path)
178 if err != nil {
179 t.Fatal(err)
180 }
181 store := New(path)
182 _, _, err = store.Open(context.Background(), "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
183 if !errors.Is(err, ErrUnsupportedVersion) {
184 t.Fatalf("Open() error = %v, want unsupported version", err)
185 }
186 after, err := os.ReadFile(path)
187 if err != nil {
188 t.Fatal(err)
189 }
190 if !bytes.Equal(before, after) {
191 t.Fatal("unknown database header or data was modified")
192 }
193 }
194
195 func TestBeginOperationIsIdempotentAndRejectsDifferentPayload(t *testing.T) {
196 ctx := context.Background()
197 store := testStore(t)
198 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
199 if err != nil {
200 t.Fatal(err)
201 }
202 base := Operation{ID: "operation-a", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session-a", TopicID: "topic-a", SubmissionID: "submission-a", Fingerprint: "same", RequestJSON: `{"input":"hello"}`}
203 first, created, err := store.BeginOperation(ctx, base)
204 if err != nil || !created {
205 t.Fatalf("BeginOperation() = created %v, err %v", created, err)
206 }
207 retry := base
208 retry.ID, retry.SessionID, retry.TopicID, retry.SubmissionID = "operation-b", "session-b", "topic-b", "submission-b"
209 second, created, err := store.BeginOperation(ctx, retry)
210 if err != nil || created {
211 t.Fatalf("retry = created %v, err %v", created, err)
212 }
213 if second.ID != first.ID || second.SessionID != first.SessionID || second.TopicID != first.TopicID || second.SubmissionID != first.SubmissionID {
214 t.Fatalf("retry operation = %+v, want %+v", second, first)
215 }
216 retry.Fingerprint = "different"
217 if _, _, err := store.BeginOperation(ctx, retry); !errors.Is(err, ErrOperationConflict) {
218 t.Fatalf("different payload error = %v", err)
219 }
220 }
221
222 func TestConvertedDraftRejectsStaleSave(t *testing.T) {
223 ctx := context.Background()
224 store := testStore(t)
225 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
226 if err != nil {
227 t.Fatal(err)
228 }
229 op, _, err := store.BeginOperation(ctx, Operation{ID: "operation-a", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session-a", SubmissionID: "submission-a", Fingerprint: "same", RequestJSON: `{}`})
230 if err != nil {
231 t.Fatal(err)
232 }
233 if _, err := store.SetOperationPhase(ctx, op.ID, "accepted", ""); err != nil {
234 t.Fatal(err)
235 }
236 if err := store.Convert(ctx, draft.ID, op.ID); err != nil {
237 t.Fatal(err)
238 }
239 if _, err := store.Save(ctx, draft.ID, draft.Revision, `{"text":"late"}`, `{}`, false); !errors.Is(err, ErrConverted) {
240 t.Fatalf("stale Save() error = %v, want converted", err)
241 }
242 active, err := store.ListActive(ctx)
243 if err != nil {
244 t.Fatal(err)
245 }
246 if len(active) != 0 {
247 t.Fatalf("active drafts = %+v, want none", active)
248 }
249 }
250
251 func TestTerminalOperationReusesReservedSession(t *testing.T) {
252 ctx := context.Background()
253 store := testStore(t)
254 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
255 if err != nil {
256 t.Fatal(err)
257 }
258 first, _, err := store.BeginOperation(ctx, Operation{ID: "operation-a", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session-a", TopicID: "topic-a", SubmissionID: "submission-a", Fingerprint: "first", RequestJSON: `{}`})
259 if err != nil {
260 t.Fatal(err)
261 }
262 if _, claimed, err := store.TransitionOperationPhase(ctx, first.ID, []string{"reserved"}, "cancelled", ""); err != nil || !claimed {
263 t.Fatalf("cancel = claimed %v, err %v", claimed, err)
264 }
265 second, created, err := store.BeginOperation(ctx, Operation{ID: "operation-b", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session-b", TopicID: "topic-b", SubmissionID: "submission-b", Fingerprint: "second", RequestJSON: `{}`})
266 if err != nil || !created {
267 t.Fatalf("second = created %v, err %v", created, err)
268 }
269 if second.SessionID != first.SessionID {
270 t.Fatalf("second session = %q, want reserved %q", second.SessionID, first.SessionID)
271 }
272 if second.TopicID != first.TopicID {
273 t.Fatalf("second topic = %q, want reserved %q", second.TopicID, first.TopicID)
274 }
275 }
276
277 func TestVersionOneDatabaseMigratesOperationTopic(t *testing.T) {
278 path := filepath.Join(t.TempDir(), "drafts.sqlite")
279 db, err := sql.Open("sqlite", path)
280 if err != nil {
281 t.Fatal(err)
282 }
283 for _, statement := range []string{
284 `CREATE TABLE operations (id TEXT PRIMARY KEY, draft_id TEXT NOT NULL, workspace_id TEXT NOT NULL, draft_revision INTEGER NOT NULL, session_id TEXT NOT NULL, submission_id TEXT NOT NULL, fingerprint TEXT NOT NULL, request_json TEXT NOT NULL, phase TEXT NOT NULL, error TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
285 `INSERT INTO operations VALUES('op','draft','workspace',1,'session','submission','fingerprint','{}','reserved','',1,1)`,
286 `PRAGMA user_version = 1`,
287 } {
288 if _, err := db.Exec(statement); err != nil {
289 t.Fatal(err)
290 }
291 }
292 if err := db.Close(); err != nil {
293 t.Fatal(err)
294 }
295 store := New(path)
296 t.Cleanup(func() { _ = store.Close() })
297 op, err := store.Operation(context.Background(), "op")
298 if err != nil {
299 t.Fatal(err)
300 }
301 if op.TopicID != "" {
302 t.Fatalf("migrated topic = %q, want empty reservation", op.TopicID)
303 }
304 var version int
305 if err := store.withDB(context.Background(), func(db *sql.DB) error { return db.QueryRow(`PRAGMA user_version`).Scan(&version) }); err != nil {
306 t.Fatal(err)
307 }
308 if version != SchemaVersion {
309 t.Fatalf("schema version = %d, want %d", version, SchemaVersion)
310 }
311 assigned, err := store.EnsureOperationTopic(context.Background(), op.ID, "topic")
312 if err != nil || assigned.TopicID != "topic" {
313 t.Fatalf("assigned = %+v, err %v", assigned, err)
314 }
315 }
316
317 func TestDispatchClaimWinsOrCancelWinsButNeverBoth(t *testing.T) {
318 ctx := context.Background()
319 store := testStore(t)
320 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
321 if err != nil {
322 t.Fatal(err)
323 }
324 op, _, err := store.BeginOperation(ctx, Operation{ID: "operation-a", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session-a", SubmissionID: "submission-a", Fingerprint: "same", RequestJSON: `{}`})
325 if err != nil {
326 t.Fatal(err)
327 }
328 if _, claimed, err := store.ClaimOperationPhase(ctx, op.ID, []string{"reserved"}, "starting"); err != nil || !claimed {
329 t.Fatalf("start = claimed %v, err %v", claimed, err)
330 }
331
332 start := make(chan struct{})
333 type outcome struct {
334 phase string
335 claimed bool
336 err error
337 }
338 results := make(chan outcome, 2)
339 for _, transition := range []struct {
340 to string
341 from []string
342 }{
343 {to: "dispatching", from: []string{"starting"}},
344 {to: "cancelled", from: []string{"reserved", "starting", "runtime_failed", "resume_required"}},
345 } {
346 go func() {
347 <-start
348 result, claimed, err := store.TransitionOperationPhase(ctx, op.ID, transition.from, transition.to, "")
349 results <- outcome{phase: result.Phase, claimed: claimed, err: err}
350 }()
351 }
352 close(start)
353 first, second := <-results, <-results
354 if first.err != nil || second.err != nil {
355 t.Fatalf("transition errors = %v, %v", first.err, second.err)
356 }
357 if first.claimed == second.claimed {
358 t.Fatalf("claims = %v, %v; want exactly one", first, second)
359 }
360 if first.phase != second.phase || (first.phase != "dispatching" && first.phase != "cancelled") {
361 t.Fatalf("final phases = %q, %q", first.phase, second.phase)
362 }
363 }
364
365 func TestAcceptAndConvertIsAtomicAndBlocksStaleSave(t *testing.T) {
366 ctx := context.Background()
367 store := testStore(t)
368 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
369 if err != nil {
370 t.Fatal(err)
371 }
372 op, _, err := store.BeginOperation(ctx, Operation{ID: "operation-a", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session-a", SubmissionID: "submission-a", Fingerprint: "same", RequestJSON: `{}`})
373 if err != nil {
374 t.Fatal(err)
375 }
376 if _, claimed, err := store.ClaimOperationPhase(ctx, op.ID, []string{"reserved"}, "starting"); err != nil || !claimed {
377 t.Fatal(err)
378 }
379 if _, claimed, err := store.ClaimOperationPhase(ctx, op.ID, []string{"starting"}, "dispatching"); err != nil || !claimed {
380 t.Fatal(err)
381 }
382 accepted, err := store.AcceptAndConvert(ctx, draft.ID, op.ID)
383 if err != nil || accepted.Phase != "accepted" {
384 t.Fatalf("accept = %+v, err %v", accepted, err)
385 }
386 if _, err := store.Save(ctx, draft.ID, draft.Revision, `{"text":"late"}`, `{}`, false); !errors.Is(err, ErrConverted) {
387 t.Fatalf("stale save error = %v", err)
388 }
389 }
390
391 func TestAcceptAndConvertRejectsNonActiveDraftWithoutAcceptingOperation(t *testing.T) {
392 ctx := context.Background()
393 store := testStore(t)
394 draft, _, err := store.Open(ctx, "workspace-a", "project", "/workspace/a", "draft-a", `{}`)
395 if err != nil {
396 t.Fatal(err)
397 }
398 op, _, err := store.BeginOperation(ctx, Operation{ID: "operation-a", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, SessionID: "session-a", SubmissionID: "submission-a", Fingerprint: "same", RequestJSON: `{}`})
399 if err != nil {
400 t.Fatal(err)
401 }
402 if _, claimed, err := store.ClaimOperationPhase(ctx, op.ID, []string{"reserved"}, "dispatching"); err != nil || !claimed {
403 t.Fatalf("dispatch claim = %v, err %v", claimed, err)
404 }
405 if err := store.withDB(ctx, func(db *sql.DB) error {
406 _, err := db.ExecContext(ctx, `UPDATE drafts SET status='discarded' WHERE id=?`, draft.ID)
407 return err
408 }); err != nil {
409 t.Fatal(err)
410 }
411 if _, err := store.AcceptAndConvert(ctx, draft.ID, op.ID); !errors.Is(err, ErrOperationConflict) {
412 t.Fatalf("AcceptAndConvert() error = %v, want operation conflict", err)
413 }
414 unchanged, err := store.Operation(ctx, op.ID)
415 if err != nil {
416 t.Fatal(err)
417 }
418 if unchanged.Phase != "dispatching" {
419 t.Fatalf("operation phase = %q, want dispatching", unchanged.Phase)
420 }
421 }
422
422 lines GO