import assert from 'node:assert/strict'; import test from 'node:test'; import db from '../src/db/models/index.ts'; import GenericDBApi from '../src/db/api/base.api.ts'; import { createEntityService } from '../src/factories/service.factory.ts'; import type { CurrentUser, DbData, EntityRecord, EntityServiceDbApi, RuntimeContext, RuntimeEnvironment, } from '../src/types/index.ts'; interface TestTransactionOptions { transaction?: unknown; } interface TestManagedTransaction { id: string; commit(): Promise; rollback(): Promise; } interface TestAssociationRecord { id?: string; name?: string; update(payload: DbData, options: TestTransactionOptions): Promise; setTags?(value: unknown, options: TestTransactionOptions): Promise; destroy?(options: TestTransactionOptions): Promise; } interface TestModelOptions extends TestTransactionOptions { attributes?: string[]; limit?: number; } interface UpdateContractCalls { findByPk?: { id: string; options: TestTransactionOptions }; findAll?: TestModelOptions; update?: { payload: DbData; options: TestTransactionOptions }; setTags?: { value: unknown; options: TestTransactionOptions }; create?: { payload: DbData; options: TestTransactionOptions }; deletedUpdate?: { payload: DbData; options: TestTransactionOptions }; deletedDestroy?: TestTransactionOptions; removedUpdate?: { payload: DbData; options: TestTransactionOptions }; removedDestroy?: TestTransactionOptions; findBy?: { where: { id: string }; options: TestTransactionOptions }; serviceUpdate?: unknown; serviceCreate?: unknown; serviceDeleteByIds?: unknown; serviceRemove?: unknown; committed?: true; rolledBack?: true; commits?: number; rollbacks?: number; } interface TestEntity extends EntityRecord { name?: string; } interface TestEntityData { name: string; } function createCurrentUser(): CurrentUser { return { id: 'user-1' }; } function createRuntimeContext(environment: RuntimeEnvironment): RuntimeContext { return { mode: 'admin', projectSlug: null, headerEnvironment: environment, }; } function isTestEntityData(data: unknown): data is TestEntityData { return data !== null && typeof data === 'object' && 'name' in data; } function replaceSequelizeTransaction( transaction: TestManagedTransaction, ): () => void { const originalTransaction = db.sequelize.transaction.bind(db.sequelize); Object.defineProperty(db.sequelize, 'transaction', { configurable: true, value: () => Promise.resolve(transaction), }); return () => { Object.defineProperty(db.sequelize, 'transaction', { configurable: true, value: originalTransaction, }); }; } function createServiceDbApi( calls: UpdateContractCalls, ): EntityServiceDbApi { return { create(options) { calls.serviceCreate = options; const data = isTestEntityData(options.data) ? options.data : { name: '' }; return Promise.resolve({ id: 'created-1', ...data }); }, bulkImport() { return Promise.resolve([]); }, findBy(where, options) { calls.findBy = { where, options }; return Promise.resolve({ id: where.id }); }, update(options) { calls.serviceUpdate = options; const data = isTestEntityData(options.data) ? options.data : { name: '' }; return Promise.resolve({ id: options.id, ...data }); }, deleteByIds(options) { calls.serviceDeleteByIds = options; return Promise.resolve([]); }, remove(options) { calls.serviceRemove = options; return Promise.resolve({ id: options.id }); }, }; } void test('GenericDBApi.update uses object signature and forwards update context', async () => { const calls: UpdateContractCalls = {}; const currentUser = createCurrentUser(); const record: TestAssociationRecord = { update(payload, options) { calls.update = { payload, options }; return Promise.resolve(); }, setTags(value, options) { calls.setTags = { value, options }; return Promise.resolve(); }, }; class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', findByPk(id: string, options: TestTransactionOptions) { calls.findByPk = { id, options }; return Promise.resolve(record); }, }; } static override get ASSOCIATIONS() { return [{ field: 'tags', setter: 'setTags', isArray: true }]; } } const result = await TestDBApi.update({ id: 'record-1', data: { name: 'Updated', tags: ['a', 'b'], skipped: undefined }, currentUser, }); assert.equal(result, record); assert.deepEqual(calls.findByPk, { id: 'record-1', options: {}, }); assert.deepEqual(calls.update, { payload: { updatedById: 'user-1', name: 'Updated', tags: ['a', 'b'], }, options: {}, }); assert.deepEqual(calls.setTags, { value: ['a', 'b'], options: {}, }); }); void test('GenericDBApi.update rejects positional signature', async () => { class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', }; } } await assert.rejects( () => TestDBApi.update('record-1'), /DBApi\.update expects an options object/, ); }); void test('GenericDBApi.create uses object signature and forwards create context', async () => { const calls: UpdateContractCalls = {}; const currentUser = createCurrentUser(); const record: TestAssociationRecord = { id: 'record-1', update() { return Promise.resolve(); }, setTags(value, options) { calls.setTags = { value, options }; return Promise.resolve(); }, }; class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', create(payload: DbData, options: TestTransactionOptions) { calls.create = { payload, options }; return Promise.resolve(record); }, }; } static override get ASSOCIATIONS() { return [{ field: 'tags', setter: 'setTags', isArray: true }]; } } const result = await TestDBApi.create({ data: { name: 'Created', tags: ['a', 'b'] }, currentUser, }); assert.equal(result, record); assert.deepEqual(calls.create, { payload: { name: 'Created', tags: ['a', 'b'], importHash: null, createdById: 'user-1', updatedById: 'user-1', }, options: {}, }); assert.deepEqual(calls.setTags, { value: ['a', 'b'], options: {}, }); }); void test('GenericDBApi.create rejects positional signature', async () => { class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', }; } } await assert.rejects( () => TestDBApi.create({ name: 'Created' }), /DBApi\.create requires \{ data \}/, ); }); void test('GenericDBApi.partialUpdate uses object signature and only updates defined fields', async () => { const calls: UpdateContractCalls = {}; const currentUser = createCurrentUser(); const record: TestAssociationRecord = { update(payload, options) { calls.update = { payload, options }; return Promise.resolve(); }, }; class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', findByPk(id: string, options: TestTransactionOptions) { calls.findByPk = { id, options }; return Promise.resolve(record); }, }; } } const result = await TestDBApi.partialUpdate({ id: 'record-1', data: { name: 'Updated', skipped: undefined }, currentUser, }); assert.equal(result, record); assert.deepEqual(calls.findByPk, { id: 'record-1', options: {}, }); assert.deepEqual(calls.update, { payload: { updatedById: 'user-1', name: 'Updated', }, options: {}, }); }); void test('GenericDBApi.partialUpdate rejects positional signature', async () => { class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', }; } } await assert.rejects( () => TestDBApi.partialUpdate('record-1'), /DBApi\.update expects an options object/, ); }); void test('createEntityService update uses object signature and manages own transaction', async () => { const calls: UpdateContractCalls = {}; const transaction: TestManagedTransaction = { id: 'tx-service-update', commit() { calls.committed = true; return Promise.resolve(); }, rollback() { calls.rolledBack = true; return Promise.resolve(); }, }; const restoreTransaction = replaceSequelizeTransaction(transaction); try { const Service = createEntityService(createServiceDbApi(calls), { entityName: 'TestEntity', }); const currentUser = createCurrentUser(); const runtimeContext = createRuntimeContext('dev'); const result = await Service.update({ id: 'record-1', data: { name: 'Updated' }, currentUser, runtimeContext, }); assert.deepEqual(result, { id: 'record-1', name: 'Updated' }); assert.deepEqual(calls.findBy, { where: { id: 'record-1' }, options: { transaction, runtimeContext }, }); assert.deepEqual(calls.serviceUpdate, { id: 'record-1', data: { name: 'Updated' }, currentUser, transaction, runtimeContext, }); assert.equal(calls.committed, true); assert.equal(calls.rolledBack, undefined); } finally { restoreTransaction(); } }); void test('createEntityService update rejects positional signature', async () => { const Service = createEntityService(createServiceDbApi({}), { entityName: 'TestEntity', }); await assert.rejects( () => Service.update({ name: 'Updated' }), /Service\.update requires \{ id, data \}/, ); }); void test('createEntityService create, deleteByIds, and remove use object signatures', async () => { const calls: UpdateContractCalls = {}; const transaction: TestManagedTransaction = { id: 'tx-service-write', commit() { calls.commits = (calls.commits ?? 0) + 1; return Promise.resolve(); }, rollback() { calls.rollbacks = (calls.rollbacks ?? 0) + 1; return Promise.resolve(); }, }; const restoreTransaction = replaceSequelizeTransaction(transaction); try { const Service = createEntityService(createServiceDbApi(calls), { entityName: 'TestEntity', }); const currentUser = createCurrentUser(); const runtimeContext = createRuntimeContext('stage'); const created = await Service.create({ data: { name: 'Created' }, currentUser, runtimeContext, }); await Service.deleteByIds({ ids: ['record-1', 'record-2'], currentUser, runtimeContext, }); await Service.remove({ id: 'record-1', currentUser, runtimeContext, }); assert.deepEqual(created, { id: 'created-1', name: 'Created' }); assert.deepEqual(calls.serviceCreate, { data: { name: 'Created' }, currentUser, transaction, runtimeContext, }); assert.deepEqual(calls.serviceDeleteByIds, { ids: ['record-1', 'record-2'], currentUser, transaction, runtimeContext, }); assert.deepEqual(calls.serviceRemove, { id: 'record-1', currentUser, transaction, runtimeContext, }); assert.equal(calls.commits, 3); assert.equal(calls.rollbacks, undefined); } finally { restoreTransaction(); } }); void test('createEntityService create, deleteByIds, and remove reject positional signatures', async () => { const Service = createEntityService(createServiceDbApi({}), { entityName: 'TestEntity', }); await assert.rejects( () => Service.create({ name: 'Created' }), /Service\.create requires \{ data \}/, ); await assert.rejects( () => Service.deleteByIds(['record-1']), /Service\.deleteByIds expects an options object/, ); await assert.rejects( () => Service.remove('record-1'), /Service\.remove expects an options object/, ); }); void test('GenericDBApi deleteByIds, remove, and findAllAutocomplete use object signatures', async () => { const calls: UpdateContractCalls = {}; const currentUser = createCurrentUser(); const deletedRecords: TestAssociationRecord[] = [ { id: 'record-1', update(payload, options) { calls.deletedUpdate = { payload, options }; return Promise.resolve(); }, destroy(options) { calls.deletedDestroy = options; return Promise.resolve(); }, }, ]; const removedRecord: TestAssociationRecord = { id: 'record-2', update(payload, options) { calls.removedUpdate = { payload, options }; return Promise.resolve(); }, destroy(options) { calls.removedDestroy = options; return Promise.resolve(); }, }; const autocompleteRecords: TestAssociationRecord[] = [ { id: 'record-3', name: 'Alpha', update: () => Promise.resolve() }, ]; class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', findAll(options: TestModelOptions) { calls.findAll = options; return Promise.resolve( options.attributes ? autocompleteRecords : deletedRecords, ); }, findByPk(id: string, options: TestTransactionOptions) { calls.findByPk = { id, options }; return Promise.resolve(removedRecord); }, }; } } const deleted = await TestDBApi.deleteByIds({ ids: ['record-1'], currentUser, }); const removed = await TestDBApi.remove({ id: 'record-2', currentUser, }); const autocomplete = await TestDBApi.findAllAutocomplete({ query: 'Alpha', limit: 5, offset: 0, }); assert.equal(deleted, deletedRecords); assert.equal(removed, removedRecord); assert.deepEqual(calls.deletedUpdate, { payload: { deletedBy: 'user-1' }, options: {}, }); assert.deepEqual(calls.deletedDestroy, {}); assert.deepEqual(calls.findByPk, { id: 'record-2', options: {}, }); assert.deepEqual(calls.removedUpdate, { payload: { deletedBy: 'user-1' }, options: {}, }); assert.deepEqual(calls.removedDestroy, {}); assert.deepEqual(autocomplete, [{ id: 'record-3', label: 'Alpha' }]); assert.equal(calls.findAll?.limit, 5); }); void test('GenericDBApi deleteByIds, remove, and findAllAutocomplete reject positional signatures', async () => { class TestDBApi extends GenericDBApi { static override get MODEL(): unknown { return { rawAttributes: {}, getTableName: () => 'test_records', }; } } await assert.rejects( () => TestDBApi.deleteByIds(['record-1']), /DBApi\.deleteByIds expects an options object/, ); await assert.rejects( () => TestDBApi.remove('record-1'), /DBApi\.remove expects an options object/, ); await assert.rejects( () => TestDBApi.findAllAutocomplete('Alpha'), /DBApi\.findAllAutocomplete expects an options object/, ); });