fix(articles): article delete failed for everyone on a phantom column (#1188)
* fix(articles): article delete failed for everyone on a phantom column Support report (odinaero.se): three unused articles could not be deleted. Root cause: the DELETE route's usage preflight filtered invoice_items by company_id, a column that table does not have, so PostgREST answered 42703 and the route mapped it to ARTICLE_DELETE_FAILED on every delete since 8a9a930f. The queued supabase mock swallows chained filters, which is why tests stayed green; a source-pin regression test now guards the query shape. Tenancy is unaffected: the preceding articles lookup already scopes the id to the company, and invoice_items rows are only reachable via that article's UUID. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: decision log for the currency-honesty batch and the phantom-column lesson Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
01d2a1a946
commit
b5e3c476a5
@@ -140,11 +140,14 @@ export const DELETE = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// invoice_items has NO company_id column: filtering on it made PostgREST
|
||||
// 42703 here, which the error branch below turned into ARTICLE_DELETE_FAILED
|
||||
// for EVERY delete (support: odinaero.se). Tenancy is already enforced by
|
||||
// the article lookup above: article_id is a UUID owned by this company.
|
||||
const { count: usageCount, error: usageError } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('article_id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (usageError) {
|
||||
opLog.error('article usage check failed', usageError)
|
||||
|
||||
@@ -165,3 +165,20 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE usage-check query shape', () => {
|
||||
it('must not filter invoice_items by company_id (column does not exist)', async () => {
|
||||
// Regression pin for the odinaero support case: invoice_items has no
|
||||
// company_id column, so filtering on it made PostgREST answer 42703 and
|
||||
// the route mapped that to ARTICLE_DELETE_FAILED for EVERY delete. The
|
||||
// queued supabase mock swallows chained filters, so no behavioral mock
|
||||
// test can catch a phantom column: pin the source instead. Tenancy is
|
||||
// enforced by the preceding articles lookup.
|
||||
const fs = await import('node:fs')
|
||||
const path = await import('node:path')
|
||||
const src = fs.readFileSync(path.resolve(__dirname, '../[id]/route.ts'), 'utf8')
|
||||
const usageBlock = src.slice(src.indexOf("from('invoice_items')"))
|
||||
const firstQuery = usageBlock.slice(0, usageBlock.indexOf('usageError'))
|
||||
expect(firstQuery).not.toContain("eq('company_id'")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user