mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
dbede59edf
Implements PLAN-2357 DR-9a. CreateItem opens and commits its own transaction, so the cross-workspace copy path (create in B + attachment remap + provenance row + optional source archive, all atomic) cannot call it. A raw in-tx `INSERT INTO items` in its place would silently break version history, wiki-links, reporting, delta sync and slug uniqueness -- none of which fail loudly. Extracted, not duplicated, and CreateItem now goes through the same function so the two paths cannot drift: - `insertItemTx` is the write half, lifted verbatim out of the old tryCreateItem body: the items INSERT (item_number, workspace seq, content-flush watermarks), the initial item_versions row, wiki-link indexing + broken-title resolution, and the create-time status_transitions row. - `createItemTx(tx, workspaceID, collectionID, input) (*models.Item, error)` wraps it with the rest of CreateItem's pipeline -- defaults, assignment-scope validation, workspace-scoped unique slug allocation -- inside a caller-owned transaction. It returns the item read back in-tx so an orchestrator can consume its slug / item_number / seq (DR-14 fanout) without a post-COMMIT round-trip. - `tryCreateItem` is now a BEGIN/COMMIT wrapper around createItemTx, and CreateItem is the retry loop around that. - `uniqueSlug` and `validateAssignmentScope` gained rowQueryer- parameterized forms (`uniqueSlugQ` / `validateAssignmentScopeQ`) so both can run on the caller's transaction. The *sql.DB entry points delegate to them; behaviour is unchanged. Content must already be final: wiki-link indexing and the first version row are written from input.Content as given, so callers doing DR-11 attachment-ref rewriting must rewrite BEFORE calling. Trust boundary is documented on the function. collectionID/workspaceID consistency and ParentID scope stay the caller's job, matching the pre-extraction tryCreateItem -- DR-9 has the orchestrator re-read and row-lock both collections in-tx, so a check here would be a second, weaker read of an already-pinned row. Assignee and agent role ARE validated, as in CreateItem. No internal retry on unique violation: a failed statement poisons the caller's transaction and an internal retry would need a savepoint the caller can't see. Fixes a latent slug race in CreateItem along the way. It used to allocate the slug ONCE, outside the transaction, and re-submit that stale value on every retry -- so two concurrent creates of the same title had the loser burn all ten attempts on a slug the winner had already committed and then fail with a unique-constraint error. Slug allocation now happens inside the transaction under the workspace advisory lock, so each attempt sees the previous scan's outcome. Two Postgres-falsifiable concurrency tests cover it (createItemTx-only and mixed CreateItem + createItemTx). 24 tests: one per DR-9a parity-checklist line, a rollback test asserting no item / version / wiki-link / status transition / seq advance survives, an in-tx-visibility test, a slug-collision test, and the two concurrency tests. Every parity assertion verified falsifiable by mutating the production code. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
791 lines
27 KiB
Go
791 lines
27 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Tests for createItemTx — the tx-taking counterpart of CreateItem
|
|
// (PLAN-2357 / DR-9a). Each test below pins one line of the DR-9a parity
|
|
// checklist: slug / item_number / seq, content-flush watermarks, the initial
|
|
// item_versions row, wiki-link indexing plus broken-title resolution, the
|
|
// create-time status_transitions row, CreateItem's defaults, and its
|
|
// assignment-scope validation. Plus the transaction-participation test: roll
|
|
// the caller's tx back and assert none of the above persisted.
|
|
|
|
// createViaTx runs createItemTx inside a fresh transaction and commits it,
|
|
// which is what a caller that has nothing else to do in the tx looks like.
|
|
func createViaTx(t *testing.T, s *Store, workspaceID, collectionID string, input models.ItemCreate) *models.Item {
|
|
t.Helper()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
item, err := s.createItemTx(tx, workspaceID, collectionID, input)
|
|
if err != nil {
|
|
t.Fatalf("createItemTx: %v", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit: %v", err)
|
|
}
|
|
return item
|
|
}
|
|
|
|
// createViaTxErr runs createItemTx and rolls back, returning the error.
|
|
func createViaTxErr(t *testing.T, s *Store, workspaceID, collectionID string, input models.ItemCreate) error {
|
|
t.Helper()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
_, err = s.createItemTx(tx, workspaceID, collectionID, input)
|
|
return err
|
|
}
|
|
|
|
func scanCount(t *testing.T, s *Store, query string, args ...any) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := s.db.QueryRow(s.q(query), args...).Scan(&n); err != nil {
|
|
t.Fatalf("count query %q: %v", query, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// itemNumber derefs models.Item.ItemNumber (*int), failing the test when the
|
|
// column came back NULL — which would itself be a parity break.
|
|
func itemNumber(t *testing.T, item *models.Item) int {
|
|
t.Helper()
|
|
if item.ItemNumber == nil {
|
|
t.Fatalf("item %s has a NULL item_number", item.ID)
|
|
}
|
|
return *item.ItemNumber
|
|
}
|
|
|
|
func maxWorkspaceSeq(t *testing.T, s *Store, workspaceID string) int64 {
|
|
t.Helper()
|
|
var seq int64
|
|
if err := s.db.QueryRow(s.q(`SELECT COALESCE(MAX(seq), 0) FROM items WHERE workspace_id = ?`), workspaceID).Scan(&seq); err != nil {
|
|
t.Fatalf("max seq: %v", err)
|
|
}
|
|
return seq
|
|
}
|
|
|
|
// --- Parity: slug allocation, scoped to the destination workspace ---
|
|
|
|
func TestCreateItemTx_AllocatesWorkspaceScopedSlug(t *testing.T) {
|
|
s := testStore(t)
|
|
wsA := createTestWorkspace(t, s, "Slug Source")
|
|
wsB := createTestWorkspace(t, s, "Slug Dest")
|
|
colA := createTestCollection(t, s, wsA.ID, "Tasks")
|
|
colB := createTestCollection(t, s, wsB.ID, "Tasks")
|
|
|
|
// Same title exists in A — must NOT influence B's allocation.
|
|
createTestItem(t, s, wsA.ID, colA.ID, "Shared Title", "")
|
|
|
|
item := createViaTx(t, s, wsB.ID, colB.ID, models.ItemCreate{Title: "Shared Title", Fields: `{"status":"open"}`})
|
|
if item.Slug != "shared-title" {
|
|
t.Fatalf("expected base slug in destination workspace, got %q", item.Slug)
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_SlugCollisionResolvesToDistinctSlug(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Slug Collision")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
first := createTestItem(t, s, ws.ID, col.ID, "Collision Title", "")
|
|
second := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Collision Title", Fields: `{"status":"open"}`})
|
|
third := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Collision Title", Fields: `{"status":"open"}`})
|
|
|
|
if first.Slug != "collision-title" {
|
|
t.Fatalf("first slug = %q", first.Slug)
|
|
}
|
|
if second.Slug != "collision-title-2" {
|
|
t.Fatalf("second slug = %q, want collision-title-2 (distinct, not an error or overwrite)", second.Slug)
|
|
}
|
|
if third.Slug != "collision-title-3" {
|
|
t.Fatalf("third slug = %q, want collision-title-3", third.Slug)
|
|
}
|
|
if first.ID == second.ID || second.ID == third.ID {
|
|
t.Fatalf("collision overwrote an existing item instead of creating a new one")
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); n != 3 {
|
|
t.Fatalf("expected 3 items, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_UntitledFallbackSlug(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Untitled Slug")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// A title that slugifies to "" must fall back to "untitled", same as CreateItem.
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "!!!", Fields: `{"status":"open"}`})
|
|
if item.Slug != "untitled" {
|
|
t.Fatalf("slug = %q, want untitled", item.Slug)
|
|
}
|
|
}
|
|
|
|
// --- Parity: item_number ---
|
|
|
|
func TestCreateItemTx_AssignsNextItemNumber(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Item Numbers")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
first := createTestItem(t, s, ws.ID, col.ID, "One", "")
|
|
second := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Two", Fields: `{"status":"open"}`})
|
|
third := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Three", Fields: `{"status":"open"}`})
|
|
|
|
if itemNumber(t, second) != itemNumber(t, first)+1 {
|
|
t.Fatalf("item_number = %d, want %d", itemNumber(t, second), itemNumber(t, first)+1)
|
|
}
|
|
if itemNumber(t, third) != itemNumber(t, second)+1 {
|
|
t.Fatalf("item_number = %d, want %d", itemNumber(t, third), itemNumber(t, second)+1)
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_ItemNumberIsWorkspaceScoped(t *testing.T) {
|
|
s := testStore(t)
|
|
wsA := createTestWorkspace(t, s, "Numbers A")
|
|
wsB := createTestWorkspace(t, s, "Numbers B")
|
|
colA := createTestCollection(t, s, wsA.ID, "Tasks")
|
|
colB := createTestCollection(t, s, wsB.ID, "Tasks")
|
|
|
|
for i := 0; i < 3; i++ {
|
|
createTestItem(t, s, wsA.ID, colA.ID, "A item", "")
|
|
}
|
|
item := createViaTx(t, s, wsB.ID, colB.ID, models.ItemCreate{Title: "B item", Fields: `{"status":"open"}`})
|
|
if got := itemNumber(t, item); got != 1 {
|
|
t.Fatalf("destination item_number = %d, want 1 (A's numbering must not leak)", got)
|
|
}
|
|
}
|
|
|
|
// --- Parity: workspace seq (delta sync cursor) ---
|
|
|
|
func TestCreateItemTx_AdvancesWorkspaceSeq(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Seq")
|
|
other := createTestWorkspace(t, s, "Seq Other")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
otherCol := createTestCollection(t, s, other.ID, "Tasks")
|
|
|
|
createTestItem(t, s, ws.ID, col.ID, "Existing", "")
|
|
before := maxWorkspaceSeq(t, s, ws.ID)
|
|
otherBefore := maxWorkspaceSeq(t, s, other.ID)
|
|
_ = otherCol
|
|
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Seq bump", Fields: `{"status":"open"}`})
|
|
|
|
if item.Seq <= before {
|
|
t.Fatalf("item seq = %d, want > %d", item.Seq, before)
|
|
}
|
|
if got := maxWorkspaceSeq(t, s, ws.ID); got != item.Seq {
|
|
t.Fatalf("workspace MAX(seq) = %d, want %d", got, item.Seq)
|
|
}
|
|
if got := maxWorkspaceSeq(t, s, other.ID); got != otherBefore {
|
|
t.Fatalf("unrelated workspace seq moved: %d -> %d", otherBefore, got)
|
|
}
|
|
}
|
|
|
|
// --- Parity: content flush watermarks ---
|
|
|
|
func TestCreateItemTx_ContentFlushWatermarks(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Watermarks")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
withContent := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Has content", Content: "body", Fields: `{"status":"open"}`})
|
|
empty := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "No content", Fields: `{"status":"open"}`})
|
|
|
|
readWatermarks := func(id string) (*string, *int64) {
|
|
var at *string
|
|
var opLogID *int64
|
|
if err := s.db.QueryRow(s.q(`SELECT content_flushed_at, content_flushed_op_log_id FROM items WHERE id = ?`), id).
|
|
Scan(&at, &opLogID); err != nil {
|
|
t.Fatalf("read watermarks: %v", err)
|
|
}
|
|
return at, opLogID
|
|
}
|
|
|
|
at, opLogID := readWatermarks(withContent.ID)
|
|
if at == nil || *at == "" {
|
|
t.Fatalf("content_flushed_at must be set when content is non-empty")
|
|
}
|
|
if opLogID == nil || *opLogID != 0 {
|
|
t.Fatalf("content_flushed_op_log_id = %v, want 0", opLogID)
|
|
}
|
|
|
|
at, opLogID = readWatermarks(empty.ID)
|
|
if at != nil || opLogID != nil {
|
|
t.Fatalf("empty content must leave both watermarks NULL, got (%v, %v)", at, opLogID)
|
|
}
|
|
}
|
|
|
|
// --- Parity: initial item_versions row ---
|
|
|
|
func TestCreateItemTx_WritesInitialVersionForNonEmptyContent(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Versions")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Versioned",
|
|
Content: "the rewritten body",
|
|
Fields: `{"status":"open"}`,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
})
|
|
|
|
var content, createdBy, source string
|
|
var versionSeq int
|
|
var isDiff bool
|
|
if err := s.db.QueryRow(s.q(`SELECT content, created_by, source, version_seq, is_diff FROM item_versions WHERE item_id = ?`), item.ID).
|
|
Scan(&content, &createdBy, &source, &versionSeq, &isDiff); err != nil {
|
|
t.Fatalf("read initial version: %v", err)
|
|
}
|
|
if content != "the rewritten body" {
|
|
t.Fatalf("version content = %q", content)
|
|
}
|
|
if createdBy != "agent" || source != "cli" {
|
|
t.Fatalf("version attribution = (%q, %q), want (agent, cli)", createdBy, source)
|
|
}
|
|
if versionSeq != 1 {
|
|
t.Fatalf("version_seq = %d, want 1", versionSeq)
|
|
}
|
|
if isDiff {
|
|
t.Fatalf("initial version must not be a diff")
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_NoVersionForEmptyContent(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Versions Empty")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Bodyless", Fields: `{"status":"open"}`})
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM item_versions WHERE item_id = ?`, item.ID); n != 0 {
|
|
t.Fatalf("expected no initial version for empty content, got %d", n)
|
|
}
|
|
}
|
|
|
|
// --- Parity: wiki-link indexing ---
|
|
|
|
func TestCreateItemTx_IndexesWikiLinksFromContent(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Wiki Index")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
target := createTestItem(t, s, ws.ID, col.ID, "Link Target", "")
|
|
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Mentions target",
|
|
Content: "see [[Link Target]] for details",
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
|
|
var targetID sql.NullString
|
|
var targetTitle sql.NullString
|
|
if err := s.db.QueryRow(s.q(`SELECT target_item_id, target_title FROM item_wiki_links WHERE source_item_id = ?`), item.ID).
|
|
Scan(&targetID, &targetTitle); err != nil {
|
|
t.Fatalf("read wiki link row: %v", err)
|
|
}
|
|
if !targetID.Valid || targetID.String != target.ID {
|
|
t.Fatalf("wiki link target = %v, want %s", targetID, target.ID)
|
|
}
|
|
if !targetTitle.Valid || targetTitle.String != "Link Target" {
|
|
t.Fatalf("target_title = %v", targetTitle)
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_ResolvesBrokenTitleLinksInDestination(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Broken Titles")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// A pre-existing item mentions a title that doesn't exist yet: the row
|
|
// lands broken (target_item_id NULL).
|
|
mentioner := createTestItem(t, s, ws.ID, col.ID, "Mentioner", "waiting on [[Arriving Later]]")
|
|
var pre sql.NullString
|
|
if err := s.db.QueryRow(s.q(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), mentioner.ID).Scan(&pre); err != nil {
|
|
t.Fatalf("read pre-arrival link: %v", err)
|
|
}
|
|
if pre.Valid {
|
|
t.Fatalf("expected a broken link row before arrival, got target %s", pre.String)
|
|
}
|
|
|
|
arrival := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Arriving Later", Fields: `{"status":"open"}`})
|
|
|
|
var post sql.NullString
|
|
if err := s.db.QueryRow(s.q(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), mentioner.ID).Scan(&post); err != nil {
|
|
t.Fatalf("read post-arrival link: %v", err)
|
|
}
|
|
if !post.Valid || post.String != arrival.ID {
|
|
t.Fatalf("broken title link did not flip to the new item: %v", post)
|
|
}
|
|
}
|
|
|
|
// --- Parity: create-time status_transitions row ---
|
|
|
|
func TestCreateItemTx_SeedsCreateTimeStatusTransition(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Transitions")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Born done", Fields: `{"status":"done"}`})
|
|
|
|
var id, fieldKey, from, to, collectionID, workspaceID string
|
|
if err := s.db.QueryRow(s.q(`
|
|
SELECT id, field_key, from_status, to_status, collection_id, workspace_id
|
|
FROM status_transitions WHERE item_id = ?
|
|
`), item.ID).Scan(&id, &fieldKey, &from, &to, &collectionID, &workspaceID); err != nil {
|
|
t.Fatalf("read create-time transition: %v", err)
|
|
}
|
|
if id != "create_"+item.ID {
|
|
t.Fatalf("transition id = %q, want create_%s (the idempotent backfill key)", id, item.ID)
|
|
}
|
|
if fieldKey != "status" || from != "" || to != "done" {
|
|
t.Fatalf("transition = (%q, %q -> %q), want (status, \"\" -> done)", fieldKey, from, to)
|
|
}
|
|
if collectionID != col.ID || workspaceID != ws.ID {
|
|
t.Fatalf("transition scoped to (%s, %s), want (%s, %s)", collectionID, workspaceID, col.ID, ws.ID)
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_NoStatusTransitionWhenDoneFieldUnset(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "No Transition")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Statusless"})
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM status_transitions WHERE item_id = ?`, item.ID); n != 0 {
|
|
t.Fatalf("expected no transition when the done field is unset, got %d", n)
|
|
}
|
|
}
|
|
|
|
// --- Parity: CreateItem's defaults ---
|
|
|
|
func TestCreateItemTx_AppliesCreateItemDefaults(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Defaults")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Bare"})
|
|
|
|
if item.Fields != "{}" {
|
|
t.Fatalf("fields default = %q, want {}", item.Fields)
|
|
}
|
|
if item.Tags != "[]" {
|
|
t.Fatalf("tags default = %q, want []", item.Tags)
|
|
}
|
|
if item.CreatedBy != "user" {
|
|
t.Fatalf("created_by default = %q, want user", item.CreatedBy)
|
|
}
|
|
if item.LastModifiedBy != "user" {
|
|
t.Fatalf("last_modified_by default = %q, want user", item.LastModifiedBy)
|
|
}
|
|
if item.Source != "web" {
|
|
t.Fatalf("source default = %q, want web", item.Source)
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_DefaultsMatchCreateItemExactly(t *testing.T) {
|
|
s := testStore(t)
|
|
wsA := createTestWorkspace(t, s, "Parity A")
|
|
wsB := createTestWorkspace(t, s, "Parity B")
|
|
colA := createTestCollection(t, s, wsA.ID, "Tasks")
|
|
colB := createTestCollection(t, s, wsB.ID, "Tasks")
|
|
|
|
input := models.ItemCreate{Title: "Parity Subject", Content: "parity body [[Nowhere]]", Fields: `{"status":"done"}`}
|
|
|
|
viaCreateItem, err := s.CreateItem(wsA.ID, colA.ID, input)
|
|
if err != nil {
|
|
t.Fatalf("CreateItem: %v", err)
|
|
}
|
|
viaTx := createViaTx(t, s, wsB.ID, colB.ID, input)
|
|
|
|
if viaTx.Slug != viaCreateItem.Slug {
|
|
t.Fatalf("slug: tx=%q createItem=%q", viaTx.Slug, viaCreateItem.Slug)
|
|
}
|
|
if itemNumber(t, viaTx) != itemNumber(t, viaCreateItem) {
|
|
t.Fatalf("item_number: tx=%d createItem=%d", itemNumber(t, viaTx), itemNumber(t, viaCreateItem))
|
|
}
|
|
if viaTx.Fields != viaCreateItem.Fields || viaTx.Tags != viaCreateItem.Tags {
|
|
t.Fatalf("fields/tags mismatch")
|
|
}
|
|
if viaTx.CreatedBy != viaCreateItem.CreatedBy || viaTx.LastModifiedBy != viaCreateItem.LastModifiedBy || viaTx.Source != viaCreateItem.Source {
|
|
t.Fatalf("attribution mismatch")
|
|
}
|
|
if viaTx.Content != viaCreateItem.Content {
|
|
t.Fatalf("content mismatch")
|
|
}
|
|
|
|
// And the same side-effect row counts on both sides.
|
|
for _, q := range []string{
|
|
`SELECT COUNT(*) FROM item_versions WHERE item_id = ?`,
|
|
`SELECT COUNT(*) FROM item_wiki_links WHERE source_item_id = ?`,
|
|
`SELECT COUNT(*) FROM status_transitions WHERE item_id = ?`,
|
|
} {
|
|
a := scanCount(t, s, q, viaCreateItem.ID)
|
|
b := scanCount(t, s, q, viaTx.ID)
|
|
if a != b {
|
|
t.Fatalf("row-count parity broken for %q: createItem=%d createItemTx=%d", q, a, b)
|
|
}
|
|
if a == 0 {
|
|
t.Fatalf("test is vacuous: %q produced no rows on either path", q)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Parity: assignment-scope validation ---
|
|
|
|
func TestCreateItemTx_RejectsForeignAssignedUser(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Assign Scope")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
outsider := createTestUser(t, s, "outsider-createtx@example.com", "Outsider", "password123")
|
|
|
|
outsiderID := outsider.ID
|
|
err := createViaTxErr(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Assigned out", AssignedUserID: &outsiderID})
|
|
if err == nil || !strings.Contains(err.Error(), "not a member of this workspace") {
|
|
t.Fatalf("expected membership rejection, got %v", err)
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); n != 0 {
|
|
t.Fatalf("rejected create still wrote an item")
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_AcceptsMemberAssignedUser(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Assign Member")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
member := createTestUser(t, s, "member-createtx@example.com", "Member", "password123")
|
|
if err := s.AddWorkspaceMember(ws.ID, member.ID, "editor"); err != nil {
|
|
t.Fatalf("AddWorkspaceMember: %v", err)
|
|
}
|
|
|
|
memberID := member.ID
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Assigned in", AssignedUserID: &memberID})
|
|
if item.AssignedUserID == nil || *item.AssignedUserID != member.ID {
|
|
t.Fatalf("assigned user not persisted: %v", item.AssignedUserID)
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_RejectsForeignAgentRole(t *testing.T) {
|
|
s := testStore(t)
|
|
wsA := createTestWorkspace(t, s, "Role Owner")
|
|
wsB := createTestWorkspace(t, s, "Role Borrower")
|
|
colB := createTestCollection(t, s, wsB.ID, "Tasks")
|
|
|
|
role, err := s.CreateAgentRole(wsA.ID, models.AgentRoleCreate{Name: "Backend"})
|
|
if err != nil {
|
|
t.Fatalf("CreateAgentRole: %v", err)
|
|
}
|
|
|
|
roleID := role.ID
|
|
err = createViaTxErr(t, s, wsB.ID, colB.ID, models.ItemCreate{Title: "Foreign role", AgentRoleID: &roleID})
|
|
if err == nil || !strings.Contains(err.Error(), "agent role does not belong to this workspace") {
|
|
t.Fatalf("expected agent-role rejection, got %v", err)
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, wsB.ID); n != 0 {
|
|
t.Fatalf("rejected create still wrote an item")
|
|
}
|
|
}
|
|
|
|
func TestCreateItemTx_AcceptsLocalAgentRole(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Role Local")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
role, err := s.CreateAgentRole(ws.ID, models.AgentRoleCreate{Name: "Backend"})
|
|
if err != nil {
|
|
t.Fatalf("CreateAgentRole: %v", err)
|
|
}
|
|
|
|
roleID := role.ID
|
|
item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Local role", AgentRoleID: &roleID})
|
|
if item.AgentRoleID == nil || *item.AgentRoleID != role.ID {
|
|
t.Fatalf("agent role not persisted: %v", item.AgentRoleID)
|
|
}
|
|
}
|
|
|
|
// --- The transaction-participation test ---
|
|
|
|
func TestCreateItemTx_RollbackPersistsNothing(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Rollback")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// A pre-existing broken title link that the rolled-back create would
|
|
// otherwise have resolved.
|
|
mentioner := createTestItem(t, s, ws.ID, col.ID, "Rollback mentioner", "waits on [[Phantom Item]]")
|
|
seqBefore := maxWorkspaceSeq(t, s, ws.ID)
|
|
itemsBefore := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID)
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
item, err := s.createItemTx(tx, ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Phantom Item",
|
|
Content: "body that mentions [[Rollback mentioner]]",
|
|
Fields: `{"status":"done"}`,
|
|
})
|
|
if err != nil {
|
|
tx.Rollback()
|
|
t.Fatalf("createItemTx: %v", err)
|
|
}
|
|
if item == nil {
|
|
tx.Rollback()
|
|
t.Fatalf("createItemTx returned nil item")
|
|
}
|
|
phantomID := item.ID
|
|
phantomSeq := item.Seq
|
|
if phantomSeq <= seqBefore {
|
|
tx.Rollback()
|
|
t.Fatalf("test is vacuous: in-tx seq %d did not advance past %d", phantomSeq, seqBefore)
|
|
}
|
|
if err := tx.Rollback(); err != nil {
|
|
t.Fatalf("rollback: %v", err)
|
|
}
|
|
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE id = ?`, phantomID); n != 0 {
|
|
t.Fatalf("item survived rollback")
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); n != itemsBefore {
|
|
t.Fatalf("workspace item count changed across rollback: %d -> %d", itemsBefore, n)
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM item_versions WHERE item_id = ?`, phantomID); n != 0 {
|
|
t.Fatalf("item_versions row survived rollback")
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM item_wiki_links WHERE source_item_id = ?`, phantomID); n != 0 {
|
|
t.Fatalf("item_wiki_links row survived rollback")
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM status_transitions WHERE item_id = ?`, phantomID); n != 0 {
|
|
t.Fatalf("status_transitions row survived rollback")
|
|
}
|
|
if got := maxWorkspaceSeq(t, s, ws.ID); got != seqBefore {
|
|
t.Fatalf("workspace seq advanced across rollback: %d -> %d", seqBefore, got)
|
|
}
|
|
// The broken-title resolution must have rolled back too.
|
|
var target sql.NullString
|
|
if err := s.db.QueryRow(s.q(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), mentioner.ID).Scan(&target); err != nil {
|
|
t.Fatalf("read mentioner link: %v", err)
|
|
}
|
|
if target.Valid {
|
|
t.Fatalf("broken-title resolution survived rollback (target=%s)", target.String)
|
|
}
|
|
|
|
// The next create reuses the seq the rolled-back one held — proof it was
|
|
// never committed.
|
|
next := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "After rollback", Fields: `{"status":"open"}`})
|
|
if next.Seq != phantomSeq {
|
|
t.Fatalf("post-rollback seq = %d, want the rolled-back value %d", next.Seq, phantomSeq)
|
|
}
|
|
}
|
|
|
|
// --- Concurrency: no retry loop, so the locks must carry it ---
|
|
|
|
// CreateItem survives a concurrent item_number claim via its retry loop.
|
|
// createItemTx has no retry (a failed statement poisons the caller's tx), so
|
|
// the workspace advisory lock — taken before the slug scan and held for the
|
|
// whole transaction — is the only thing standing between concurrent copies
|
|
// and duplicate item_numbers or duplicate slugs. Runs on both dialects:
|
|
// Postgres exercises pg_advisory_xact_lock, SQLite exercises BEGIN IMMEDIATE.
|
|
func TestCreateItemTx_ConcurrentCreatesGetDistinctNumbersAndSlugs(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Concurrent Create")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
const n = 8
|
|
type result struct {
|
|
slug string
|
|
number int
|
|
seq int64
|
|
err error
|
|
}
|
|
results := make([]result, n)
|
|
start := make(chan struct{})
|
|
done := make(chan int, n)
|
|
|
|
for i := 0; i < n; i++ {
|
|
go func(i int) {
|
|
<-start
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
results[i] = result{err: err}
|
|
done <- i
|
|
return
|
|
}
|
|
item, err := s.createItemTx(tx, ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Racing Title",
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
if err != nil {
|
|
tx.Rollback()
|
|
results[i] = result{err: err}
|
|
done <- i
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
results[i] = result{err: err}
|
|
done <- i
|
|
return
|
|
}
|
|
if item.ItemNumber == nil {
|
|
results[i] = result{err: fmt.Errorf("item %s has a NULL item_number", item.ID)}
|
|
done <- i
|
|
return
|
|
}
|
|
results[i] = result{slug: item.Slug, number: *item.ItemNumber, seq: item.Seq}
|
|
done <- i
|
|
}(i)
|
|
}
|
|
close(start)
|
|
for i := 0; i < n; i++ {
|
|
<-done
|
|
}
|
|
|
|
slugs := map[string]bool{}
|
|
numbers := map[int]bool{}
|
|
seqs := map[int64]bool{}
|
|
for i, r := range results {
|
|
if r.err != nil {
|
|
t.Fatalf("goroutine %d: %v", i, r.err)
|
|
}
|
|
if slugs[r.slug] {
|
|
t.Fatalf("duplicate slug %q across concurrent creates", r.slug)
|
|
}
|
|
if numbers[r.number] {
|
|
t.Fatalf("duplicate item_number %d across concurrent creates", r.number)
|
|
}
|
|
if seqs[r.seq] {
|
|
t.Fatalf("duplicate seq %d across concurrent creates", r.seq)
|
|
}
|
|
slugs[r.slug] = true
|
|
numbers[r.number] = true
|
|
seqs[r.seq] = true
|
|
}
|
|
if got := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); got != n {
|
|
t.Fatalf("committed %d items, want %d", got, n)
|
|
}
|
|
if got := scanCount(t, s, `SELECT COUNT(DISTINCT slug) FROM items WHERE workspace_id = ?`, ws.ID); got != n {
|
|
t.Fatalf("%d distinct slugs persisted, want %d", got, n)
|
|
}
|
|
}
|
|
|
|
// The same race across the two entry points: half the writers go through the
|
|
// public CreateItem, half through createItemTx on their own transaction. Both
|
|
// now allocate the slug inside the transaction under the workspace lock, so
|
|
// neither can hand the other a stale scan. Before that change CreateItem
|
|
// allocated its slug before opening the transaction and re-submitted the same
|
|
// stale value on every retry, so a concurrent same-title create made it burn
|
|
// all ten attempts and fail with a unique-constraint error.
|
|
func TestCreateItemTx_MixedWithCreateItemConcurrently(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Mixed Concurrent")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
const n = 8
|
|
errs := make([]error, n)
|
|
slugs := make([]string, n)
|
|
start := make(chan struct{})
|
|
done := make(chan struct{}, n)
|
|
|
|
for i := 0; i < n; i++ {
|
|
go func(i int) {
|
|
defer func() { done <- struct{}{} }()
|
|
<-start
|
|
input := models.ItemCreate{Title: "Mixed Racing Title", Fields: `{"status":"open"}`}
|
|
if i%2 == 0 {
|
|
item, err := s.CreateItem(ws.ID, col.ID, input)
|
|
if err != nil {
|
|
errs[i] = err
|
|
return
|
|
}
|
|
slugs[i] = item.Slug
|
|
return
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
errs[i] = err
|
|
return
|
|
}
|
|
item, err := s.createItemTx(tx, ws.ID, col.ID, input)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
errs[i] = err
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
errs[i] = err
|
|
return
|
|
}
|
|
slugs[i] = item.Slug
|
|
}(i)
|
|
}
|
|
close(start)
|
|
for i := 0; i < n; i++ {
|
|
<-done
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("writer %d (%s path) failed: %v", i, map[bool]string{true: "CreateItem", false: "createItemTx"}[i%2 == 0], err)
|
|
}
|
|
if seen[slugs[i]] {
|
|
t.Fatalf("duplicate slug %q across mixed-path concurrent creates", slugs[i])
|
|
}
|
|
seen[slugs[i]] = true
|
|
}
|
|
if got := scanCount(t, s, `SELECT COUNT(DISTINCT slug) FROM items WHERE workspace_id = ?`, ws.ID); got != n {
|
|
t.Fatalf("%d distinct slugs persisted, want %d", got, n)
|
|
}
|
|
}
|
|
|
|
// A caller doing more work in the same transaction sees the created item, and
|
|
// its own later failure takes the create down with it.
|
|
func TestCreateItemTx_VisibleToCallerBeforeCommit(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "In-tx Visibility")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
item, err := s.createItemTx(tx, ws.ID, col.ID, models.ItemCreate{Title: "Visible", Content: "x", Fields: `{"status":"open"}`})
|
|
if err != nil {
|
|
t.Fatalf("createItemTx: %v", err)
|
|
}
|
|
|
|
// Uncommitted, so invisible outside the tx...
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE id = ?`, item.ID); n != 0 {
|
|
t.Fatalf("uncommitted item visible outside the transaction")
|
|
}
|
|
// ...but readable inside it, which is what an orchestrator needs to write
|
|
// a provenance row referencing the new item in the same tx.
|
|
var n int
|
|
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM items WHERE id = ?`), item.ID).Scan(&n); err != nil {
|
|
t.Fatalf("in-tx read: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("created item not visible inside its own transaction")
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit: %v", err)
|
|
}
|
|
if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE id = ?`, item.ID); n != 1 {
|
|
t.Fatalf("item missing after commit")
|
|
}
|
|
}
|