diff --git a/scripts/test-unique-names.js b/scripts/test-unique-names.js new file mode 100644 index 0000000..4e59ffd --- /dev/null +++ b/scripts/test-unique-names.js @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import LibDashStore from '../src/index.js'; +import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; + +async function run() { + console.log('--- Testing uniqueness guards: attributename / attributevalue / propertyname ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-unique-names-')); + const provider = new BetterSqlite3Provider({ + filename: join(tempDir, 'test.db'), + storageDir: join(tempDir, 'storage'), + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + const googleAuthId = await store.google_auth.add('unique-tester@example.com'); + const company = await store.company.create({ + name: 'Unique Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + console.log('1. POST /attributename with a duplicate name must throw a stable conflict error...'); + const an1 = await store.attributename.create(googleAuthId, { name: 'Farbe' }); + await assert.rejects( + () => store.attributename.create(googleAuthId, { name: 'Farbe' }), + { message: 'Attribute name already exists' }, + ); + + console.log('2. POST /attributename/{id}/value with a duplicate value...'); + const av1 = await store.attributevalue.create(googleAuthId, an1.id, { value: 'Rot' }); + await assert.rejects( + () => store.attributevalue.create(googleAuthId, an1.id, { value: 'Rot' }), + { message: 'Attribute value already exists' }, + ); + + console.log('3. POST /propertyname with a duplicate name...'); + const pn1 = await store.propertyname.create(googleAuthId, { name: 'Notiz' }); + await assert.rejects( + () => store.propertyname.create(googleAuthId, { name: 'Notiz' }), + { message: 'Property name already exists' }, + ); + + console.log('4. PATCH rename onto an existing name/value...'); + const an2 = await store.attributename.create(googleAuthId, { name: 'Größe' }); + await assert.rejects( + () => store.attributename.update(googleAuthId, an2.id, { name: 'Farbe' }), + { message: 'Attribute name already exists' }, + ); + + const av2 = await store.attributevalue.create(googleAuthId, an1.id, { value: 'Blau' }); + await assert.rejects( + () => store.attributevalue.update(googleAuthId, av2.id, { value: 'Rot' }), + { message: 'Attribute value already exists' }, + ); + + const pn2 = await store.propertyname.create(googleAuthId, { name: 'Länge' }); + await assert.rejects( + () => store.propertyname.update(googleAuthId, pn2.id, { name: 'Notiz' }), + { message: 'Property name already exists' }, + ); + + console.log('5. Nothing was persisted by the rejected writes...'); + const anList = await store.attributename.list(googleAuthId); + assert.deepEqual(anList.map((a) => a.name).sort(), ['Farbe', 'Größe']); + const avList = await store.attributevalue.listByAttributeId(googleAuthId, an1.id); + assert.deepEqual(avList.map((v) => v.value).sort(), ['Blau', 'Rot']); + const pnList = await store.propertyname.list(googleAuthId); + assert.deepEqual(pnList.map((p) => p.name).sort(), ['Länge', 'Notiz']); + + console.log('6. Renames to a free name still work...'); + await store.attributename.update(googleAuthId, an2.id, { name: 'Größe 2' }); + await store.propertyname.update(googleAuthId, pn2.id, { name: 'Länge 2' }); + await store.attributevalue.update(googleAuthId, av2.id, { value: 'Grün' }); + const renamed = await store.attributevalue.listByAttributeId(googleAuthId, an1.id); + assert.ok(renamed.some((v) => v.value === 'Grün')); + + console.log('7. Same name in a different company is allowed (uniqueness is company-scoped)...'); + const otherId = await store.google_auth.add('other-tester@example.com'); + const otherCompany = await store.company.create({ + name: 'Other Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId: otherId, + }); + await store.google_auth.setSelectedCompany(otherId, otherCompany.id); + const foreign = await store.attributename.create(otherId, { name: 'Farbe' }); + assert.ok(foreign.id); + + console.log('\nPASS - all uniqueness guards throw stable, greppable errors and persist nothing.'); + } finally { + await provider.purge(); + rmSync(tempDir, { recursive: true, force: true }); + } +} + +run().catch((err) => { + console.error('\nFAIL:', err?.message ?? err); + process.exit(1); +}); \ No newline at end of file diff --git a/src/store/attributename/create.js b/src/store/attributename/create.js index 859781c..0f9dae2 100644 --- a/src/store/attributename/create.js +++ b/src/store/attributename/create.js @@ -6,6 +6,7 @@ import { CREATE as SQLITE_CREATE, GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, } from '../../providers/sqlite3/queries/attributename.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; @@ -25,6 +26,8 @@ export default function create(provider) { * @param {boolean} [input.exclusive=false] - Whether values are mutually exclusive on an article. * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>} + * + * @throws {Error} If the name is missing or already exists for the company. */ return async (googleAuthId, input = {}, tx) => { const name = input.name?.trim(); @@ -43,12 +46,20 @@ export default function create(provider) { const exclusive = Boolean(input.exclusive) ? 1 : 0; - const result = await runner.query(createSql, { - companyId, - name, - sort, - exclusive, - }); + let result; + try { + result = await runner.query(createSql, { + companyId, + name, + sort, + exclusive, + }); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Attribute name already exists'); + } + throw err; + } const id = result.rows[0]?.id ?? result.insertId; return { diff --git a/src/store/attributename/update.js b/src/store/attributename/update.js index aecb459..29320c8 100644 --- a/src/store/attributename/update.js +++ b/src/store/attributename/update.js @@ -6,6 +6,7 @@ import { GET_BY_COMPANY_AND_ID as SQLITE_GET, UPDATE_BY_ID as SQLITE_UPDATE, } from '../../providers/sqlite3/queries/attributename.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; @@ -32,6 +33,8 @@ export default function update(provider) { * @param {boolean} [input.exclusive] - New exclusive flag. * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>} + * + * @throws {Error} If the attribute name is unknown, the name is missing, or the name is already taken. */ return async (googleAuthId, id, input = {}, tx) => { if (!id || !Number.isInteger(Number(id))) { @@ -53,13 +56,20 @@ export default function update(provider) { const sort = input.sort !== undefined && Number.isInteger(input.sort) ? input.sort : existing.rows[0].sort; const exclusive = input.exclusive !== undefined ? (Boolean(input.exclusive) ? 1 : 0) : existing.rows[0].exclusive; - await runner.query(updateSql, { - id, - companyId, - name, - sort, - exclusive, - }); + try { + await runner.query(updateSql, { + id, + companyId, + name, + sort, + exclusive, + }); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Attribute name already exists'); + } + throw err; + } return { id, diff --git a/src/store/attributevalue/create.js b/src/store/attributevalue/create.js index 9a179b0..6d0e976 100644 --- a/src/store/attributevalue/create.js +++ b/src/store/attributevalue/create.js @@ -12,6 +12,7 @@ import { import { GET_BY_COMPANY_AND_ID as SQLITE_GET_ATTR, } from '../../providers/sqlite3/queries/attributename.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; @@ -32,6 +33,8 @@ export default function create(provider) { * @param {number} [input.sort] - Optional sort order. * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, attributeId: number, value: string, sort: number}>} + * + * @throws {Error} If the value is missing, the attribute is unknown, or the value already exists for the attribute. */ return async (googleAuthId, attributeId, input = {}, tx) => { const attrId = Number(attributeId); @@ -57,11 +60,19 @@ export default function create(provider) { sort = next.rows[0].sort; } - const result = await runner.query(createSql, { - attributeId: attrId, - value, - sort, - }); + let result; + try { + result = await runner.query(createSql, { + attributeId: attrId, + value, + sort, + }); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Attribute value already exists'); + } + throw err; + } const id = result.rows[0]?.id ?? result.insertId; return { diff --git a/src/store/attributevalue/update.js b/src/store/attributevalue/update.js index 34d93f2..e4d3d38 100644 --- a/src/store/attributevalue/update.js +++ b/src/store/attributevalue/update.js @@ -6,6 +6,7 @@ import { GET_BY_ID_AND_COMPANY as SQLITE_GET, UPDATE_BY_ID as SQLITE_UPDATE, } from '../../providers/sqlite3/queries/attributevalue.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; @@ -25,6 +26,8 @@ export default function update(provider) { * @param {number} [input.sort] - New sort order. * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, attributeId: number, value: string, sort: number}>} + * + * @throws {Error} If the attribute value is unknown, the value is missing, or the value is already taken. */ return async (googleAuthId, id, input = {}, tx) => { const valId = Number(id); @@ -46,11 +49,18 @@ export default function update(provider) { } const sort = input.sort !== undefined && Number.isInteger(input.sort) ? input.sort : existing.rows[0].sort; - await runner.query(updateSql, { - id: valId, - value, - sort, - }); + try { + await runner.query(updateSql, { + id: valId, + value, + sort, + }); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Attribute value already exists'); + } + throw err; + } return { id: valId, diff --git a/src/store/propertyname/create.js b/src/store/propertyname/create.js index 62c10c3..565f2f7 100644 --- a/src/store/propertyname/create.js +++ b/src/store/propertyname/create.js @@ -6,6 +6,7 @@ import { CREATE as SQLITE_CREATE, GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, } from '../../providers/sqlite3/queries/propertyname.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; @@ -25,6 +26,8 @@ export default function create(provider) { * @param {boolean} [input.exclusive=false] - Whether values are mutually exclusive on an article. * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean}>} + * + * @throws {Error} If the name is missing or already exists for the company. */ return async (googleAuthId, input = {}, tx) => { const name = input.name?.trim(); @@ -43,12 +46,20 @@ export default function create(provider) { const exclusive = Boolean(input.exclusive) ? 1 : 0; - const result = await runner.query(createSql, { - companyId, - name, - sort, - exclusive, - }); + let result; + try { + result = await runner.query(createSql, { + companyId, + name, + sort, + exclusive, + }); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Property name already exists'); + } + throw err; + } const id = result.rows[0]?.id ?? result.insertId; return { id, name, sort, exclusive: Boolean(exclusive) }; diff --git a/src/store/propertyname/update.js b/src/store/propertyname/update.js index 83659ca..235a1a9 100644 --- a/src/store/propertyname/update.js +++ b/src/store/propertyname/update.js @@ -6,6 +6,7 @@ import { GET_BY_COMPANY_AND_ID as SQLITE_GET, UPDATE_BY_ID as SQLITE_UPDATE, } from '../../providers/sqlite3/queries/propertyname.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; @@ -26,6 +27,8 @@ export default function update(provider) { * @param {boolean} [input.exclusive] - New exclusive flag. * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean}>} + * + * @throws {Error} If the property name is unknown, the name is missing, or the name is already taken. */ return async (googleAuthId, id, input = {}, tx) => { if (!id || !Number.isInteger(Number(id))) { @@ -57,13 +60,20 @@ export default function update(provider) { } } - await runner.query(updateSql, { - id, - companyId, - name, - sort, - exclusive, - }); + try { + await runner.query(updateSql, { + id, + companyId, + name, + sort, + exclusive, + }); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Property name already exists'); + } + throw err; + } return { id, name, sort, exclusive: Boolean(exclusive) }; };