fix(t243): allow deleting unreferenced procurement drafts
This commit is contained in:
@@ -140,22 +140,125 @@ func TestDeleteFreightOrderIsScopedIdempotentAndReimportable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
||||
func TestDeleteFreightOrderRemovesUnprotectedProcurementDrafts(
|
||||
t *testing.T,
|
||||
) {
|
||||
for index, status := range []domain.ProcurementRequestStatus{
|
||||
domain.ProcurementBlocked,
|
||||
domain.ProcurementNeedsImage,
|
||||
domain.ProcurementSourceChanged,
|
||||
} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
store, _ := repository.New(db)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
|
||||
userID := uuid(2430 + index*10)
|
||||
seedFreightUser(t, db, userID, now)
|
||||
|
||||
run := freightRun(2431+index*10, userID, now)
|
||||
createAndStartFreightRun(
|
||||
t,
|
||||
store,
|
||||
run,
|
||||
"delete-draft-"+string(status),
|
||||
"4",
|
||||
)
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
run,
|
||||
freightBatch(2432+index*10, "a", "b"),
|
||||
now.Add(time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||
}
|
||||
orders, _ := store.ListFreightOrders(
|
||||
ctx,
|
||||
"local-admin",
|
||||
10,
|
||||
)
|
||||
detail, _ := store.GetFreightOrder(
|
||||
ctx,
|
||||
"local-admin",
|
||||
orders[0].ID,
|
||||
)
|
||||
procurement, err := usecase.NewProcurementService(
|
||||
store,
|
||||
procurementClock{now: now.Add(2 * time.Second)},
|
||||
&procurementIDs{next: 2440 + index*10},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewProcurementService() error = %v", err)
|
||||
}
|
||||
result, err := procurement.CreateRequest(
|
||||
ctx,
|
||||
usecase.CreateProcurementRequestCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: userID,
|
||||
FreightOrderItemID: detail.Items[0].ID,
|
||||
ConfirmProcurementNeeded: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRequest() error = %v", err)
|
||||
}
|
||||
if status != domain.ProcurementNeedsImage {
|
||||
blockingCode := "TITLE_REQUIRED"
|
||||
if status == domain.ProcurementSourceChanged {
|
||||
blockingCode = "SOURCE_CHANGED"
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`UPDATE procurement_requests
|
||||
SET status = ?, blocking_code = ?
|
||||
WHERE id = ?`,
|
||||
status,
|
||||
blockingCode,
|
||||
result.Request.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("set draft status %s: %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
keys, err := store.DeleteFreightOrder(
|
||||
ctx,
|
||||
"local-admin",
|
||||
orders[0].ID,
|
||||
)
|
||||
if err != nil || len(keys) != 0 {
|
||||
t.Fatalf("draft delete = %#v, %v", keys, err)
|
||||
}
|
||||
for table, want := range map[string]int{
|
||||
"freight_orders": 0,
|
||||
"freight_order_items": 0,
|
||||
"procurement_requests": 0,
|
||||
} {
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
"SELECT count(*) FROM " + table,
|
||||
).Scan(&count); err != nil || count != want {
|
||||
t.Fatalf("%s count = %d, %v", table, count, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFreightOrderProtectsReferenceAndTaskAtomically(
|
||||
t *testing.T,
|
||||
) {
|
||||
db := openDatabase(t)
|
||||
store, _ := repository.New(db)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
|
||||
userID := uuid(2430)
|
||||
now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||
userID := uuid(2470)
|
||||
seedFreightUser(t, db, userID, now)
|
||||
|
||||
run := freightRun(2431, userID, now)
|
||||
createAndStartFreightRun(t, store, run, "delete-conflict", "4")
|
||||
run := freightRun(2471, userID, now)
|
||||
createAndStartFreightRun(t, store, run, "delete-protected", "5")
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
run,
|
||||
freightBatch(2432, "a", "b"),
|
||||
freightBatch(2472, "a", "b"),
|
||||
now.Add(time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||
@@ -169,12 +272,12 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
||||
procurement, err := usecase.NewProcurementService(
|
||||
store,
|
||||
procurementClock{now: now.Add(2 * time.Second)},
|
||||
&procurementIDs{next: 2440},
|
||||
&procurementIDs{next: 2480},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewProcurementService() error = %v", err)
|
||||
}
|
||||
if _, err := procurement.CreateRequest(
|
||||
result, err := procurement.CreateRequest(
|
||||
ctx,
|
||||
usecase.CreateProcurementRequestCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
@@ -182,22 +285,75 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
||||
FreightOrderItemID: detail.Items[0].ID,
|
||||
ConfirmProcurementNeeded: true,
|
||||
},
|
||||
); err != nil {
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRequest() error = %v", err)
|
||||
}
|
||||
asset := domain.Asset{
|
||||
ID: uuid(2490),
|
||||
CreatorSubject: "local-admin",
|
||||
Purpose: domain.AssetPurposeTaskReference,
|
||||
MediaType: domain.NormalizedImageMediaType,
|
||||
SizeBytes: 100,
|
||||
SHA256: repeatHex("f"),
|
||||
StorageKey: "freight-delete/reference.jpg",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if _, _, err := store.CreateAssetIdempotent(
|
||||
ctx,
|
||||
asset,
|
||||
"freight-delete-reference",
|
||||
repeatHex("e"),
|
||||
); err != nil {
|
||||
t.Fatalf("CreateAssetIdempotent() error = %v", err)
|
||||
}
|
||||
if _, err := procurement.BindReference(
|
||||
ctx,
|
||||
usecase.BindProcurementReferenceCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: userID,
|
||||
RequestID: result.Request.ID,
|
||||
ImageAssetID: asset.ID,
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("BindReference() error = %v", err)
|
||||
}
|
||||
|
||||
keys, err := store.DeleteFreightOrder(
|
||||
ctx,
|
||||
"local-admin",
|
||||
orders[0].ID,
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrFreightOrderInUse) || len(keys) != 0 {
|
||||
t.Fatalf("conflicting delete = %#v, %v", keys, err)
|
||||
if !errors.Is(err, usecase.ErrFreightOrderHasReference) ||
|
||||
len(keys) != 0 {
|
||||
t.Fatalf("reference delete = %#v, %v", keys, err)
|
||||
}
|
||||
if _, err := procurement.CreateTask(
|
||||
ctx,
|
||||
usecase.CreateProcurementTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: userID,
|
||||
RequestID: result.Request.ID,
|
||||
IdempotencyKey: "freight-delete-task",
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("CreateTask() error = %v", err)
|
||||
}
|
||||
keys, err = store.DeleteFreightOrder(
|
||||
ctx,
|
||||
"local-admin",
|
||||
orders[0].ID,
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrFreightOrderHasPurchaseTask) ||
|
||||
len(keys) != 0 {
|
||||
t.Fatalf("task delete = %#v, %v", keys, err)
|
||||
}
|
||||
for table, want := range map[string]int{
|
||||
"freight_orders": 1,
|
||||
"freight_order_items": 2,
|
||||
"procurement_requests": 1,
|
||||
"freight_orders": 1,
|
||||
"freight_order_items": 2,
|
||||
"procurement_requests": 1,
|
||||
"purchase_tasks": 1,
|
||||
"purchase_task_sources": 1,
|
||||
} {
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(
|
||||
|
||||
@@ -642,7 +642,63 @@ func (store *Store) DeleteFreightOrder(
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
|
||||
var inUse int
|
||||
var hasPurchaseTask int
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight_order_items AS item
|
||||
JOIN procurement_requests AS request
|
||||
ON request.freight_order_item_id = item.id
|
||||
WHERE item.freight_order_id = ?
|
||||
AND request.purchase_task_id IS NOT NULL
|
||||
)`,
|
||||
storedOrderID,
|
||||
).Scan(&hasPurchaseTask)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if hasPurchaseTask != 0 {
|
||||
return nil, usecase.ErrFreightOrderHasPurchaseTask
|
||||
}
|
||||
|
||||
var hasReference int
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight_order_items AS item
|
||||
JOIN procurement_requests AS request
|
||||
ON request.freight_order_item_id = item.id
|
||||
WHERE item.freight_order_id = ?
|
||||
AND request.reference_asset_id IS NOT NULL
|
||||
)`,
|
||||
storedOrderID,
|
||||
).Scan(&hasReference)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if hasReference != 0 {
|
||||
return nil, usecase.ErrFreightOrderHasReference
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
`DELETE FROM procurement_requests
|
||||
WHERE freight_order_item_id IN (
|
||||
SELECT id
|
||||
FROM freight_order_items
|
||||
WHERE freight_order_id = ?
|
||||
)
|
||||
AND reference_asset_id IS NULL
|
||||
AND purchase_task_id IS NULL
|
||||
AND status IN ('BLOCKED', 'NEEDS_IMAGE', 'SOURCE_CHANGED')`,
|
||||
storedOrderID,
|
||||
); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
|
||||
var remainingRequests int
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS (
|
||||
@@ -653,12 +709,12 @@ func (store *Store) DeleteFreightOrder(
|
||||
WHERE item.freight_order_id = ?
|
||||
)`,
|
||||
storedOrderID,
|
||||
).Scan(&inUse)
|
||||
).Scan(&remainingRequests)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if inUse != 0 {
|
||||
return nil, usecase.ErrFreightOrderInUse
|
||||
if remainingRequests != 0 {
|
||||
return nil, repositoryFailure(usecase.ErrRepositoryInvariant)
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(
|
||||
|
||||
Reference in New Issue
Block a user