diff --git a/internal/store/prompt_local_origin_test.go b/internal/store/prompt_local_origin_test.go index 200bf908e..879200460 100644 --- a/internal/store/prompt_local_origin_test.go +++ b/internal/store/prompt_local_origin_test.go @@ -2,6 +2,96 @@ package store import "testing" +func TestPreviewPromptSource(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("session", "alpha", "/work"); err != nil { + t.Fatal(err) + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "session", Project: "beta", SourceInboxID: "local", Content: "local"}) + if err != nil { + t.Fatal(err) + } + var key string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, id).Scan(&key); err != nil { + t.Fatal(err) + } + type previewCase struct { + name, key string + want PromptSourcePreview + found bool + } + cases := []previewCase{ + {"verified local", key, PromptSourcePreview{"session", "local", "beta", key, "live"}, true}, + {"missing", "missing", PromptSourcePreview{}, false}, + {"near-match sync ID", key + "-other", PromptSourcePreview{}, false}, + {"empty key", "", PromptSourcePreview{}, false}, + } + check := func(tc previewCase) { + t.Helper() + t.Run(tc.name, func(t *testing.T) { + got, found, err := s.PreviewPromptSource(tc.key) + if err != nil || found != tc.found || got != tc.want { + t.Fatalf("preview %q: got %+v, found %v, err %v; want %+v, found %v", tc.key, got, found, err, tc.want, tc.found) + } + }) + } + for _, tc := range cases { + check(tc) + } + if _, err := s.DB().Exec(`UPDATE user_prompts SET local_creation_session_id=NULL,local_creation_inbox_id=NULL,local_creation_project=NULL WHERE id=?`, id); err != nil { + t.Fatal(err) + } + check(previewCase{"unmarked local row", key, cases[0].want, true}) + + fixtures := []struct { + name, statement string + }{ + {"old imported-style row", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('old','session','old-key','beta','old')`}, + {"tombstone", `INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('deleted','session','gone','beta')`}, + {"duplicate live", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('old','session','second','beta','duplicate')`}, + {"live plus tombstone", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('deleted','session','live','beta','conflict')`}, + {"idless", `INSERT INTO user_prompts(sync_id,session_id,project,content) VALUES ('idless','session','beta','idless')`}, + {"blank project", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('blank-project','session','inbox','','blank')`}, + {"blank session", `INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('blank-session','','inbox','beta')`}, + {"whitespace-only identity", `INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('whitespace-identity',' ',' ',' ')`}, + } + for i, fixture := range fixtures { + if _, err := s.DB().Exec(fixture.statement); err != nil { + t.Fatalf("%s: %v", fixture.name, err) + } + if i == 0 { + check(previewCase{fixture.name, "old", PromptSourcePreview{"session", "old-key", "beta", "old", "live"}, true}) + } + if i == 1 { + check(previewCase{fixture.name, "deleted", PromptSourcePreview{"session", "gone", "beta", "deleted", "deleted"}, true}) + } + } + for _, tc := range []previewCase{ + {"duplicate live", "old", PromptSourcePreview{}, false}, + {"live plus tombstone", "deleted", PromptSourcePreview{}, false}, + {"idless", "idless", PromptSourcePreview{}, false}, + {"blank project", "blank-project", PromptSourcePreview{}, false}, + {"blank session", "blank-session", PromptSourcePreview{}, false}, + {"whitespace-only identity", "whitespace-identity", PromptSourcePreview{}, false}, + } { + check(tc) + } + var originCount int + if err := s.DB().QueryRow(`SELECT count(*) FROM user_prompts WHERE id=? AND local_creation_session_id IS NULL AND local_creation_inbox_id IS NULL AND local_creation_project IS NULL`, id).Scan(&originCount); err != nil || originCount != 1 { + t.Fatalf("origin changed: %d %v", originCount, err) + } +} + +func TestPreviewPromptSourceDBError(t *testing.T) { + s := newTestStore(t) // isolated temp database; schema damage cannot affect another test. + if _, err := s.DB().Exec(`DROP TABLE prompt_tombstones`); err != nil { + t.Fatal(err) + } + if got, found, err := s.PreviewPromptSource("missing"); err == nil || found || got != (PromptSourcePreview{}) { + t.Fatalf("DB error: %+v %v %v", got, found, err) + } +} + func TestLocalPromptCreationIdentity(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("session", "alpha", "/work"); err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 7996292e6..281040c14 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3920,6 +3920,50 @@ func (s *Store) AddPromptWithResult(p AddPromptParams) (int64, bool, error) { return promptID, inserted, nil } +// PromptSourcePreview describes observed prompt data, not ownership or authority. +type PromptSourcePreview struct { + SessionID string + SourceInboxID string + Project string + SyncID string + Kind string // live or deleted +} + +// PreviewPromptSource reads one exact sync ID from live prompts or tombstones. +// Ambiguous or incomplete observations return no preview; project is prompt data only. +func (s *Store) PreviewPromptSource(syncID string) (PromptSourcePreview, bool, error) { + if strings.TrimSpace(syncID) == "" { + return PromptSourcePreview{}, false, nil + } + rows, err := s.db.Query(`SELECT ifnull(session_id,''), ifnull(source_inbox_id,''), ifnull(project,''), ifnull(sync_id,''), 'live' + FROM user_prompts WHERE sync_id = ? + UNION ALL + SELECT ifnull(session_id,''), ifnull(source_inbox_id,''), ifnull(project,''), ifnull(sync_id,''), 'deleted' + FROM prompt_tombstones WHERE sync_id = ? LIMIT 2`, syncID, syncID) + if err != nil { + return PromptSourcePreview{}, false, err + } + defer func() { _ = rows.Close() }() + var preview PromptSourcePreview + if !rows.Next() { + return preview, false, rows.Err() + } + if err := rows.Scan(&preview.SessionID, &preview.SourceInboxID, &preview.Project, &preview.SyncID, &preview.Kind); err != nil { + return PromptSourcePreview{}, false, err + } + if rows.Next() { + return PromptSourcePreview{}, false, nil + } + if err := rows.Err(); err != nil { + return PromptSourcePreview{}, false, err + } + if strings.TrimSpace(preview.SessionID) == "" || strings.TrimSpace(preview.SourceInboxID) == "" || + strings.TrimSpace(preview.Project) == "" || strings.TrimSpace(preview.SyncID) == "" { + return PromptSourcePreview{}, false, nil + } + return preview, true, nil +} + // LocalPromptCreationIdentity returns verified local creation identity by exact sync ID. // A tombstone is eligible only when its separately recorded local origin matches. func (s *Store) LocalPromptCreationIdentity(syncID string) (session, inbox, project string, eligible bool, err error) {