39948-vm/backend/tests/update-contracts.test.ts
2026-07-01 15:45:38 +02:00

583 lines
15 KiB
TypeScript

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<void>;
rollback(): Promise<void>;
}
interface TestAssociationRecord {
id?: string;
name?: string;
update(payload: DbData, options: TestTransactionOptions): Promise<void>;
setTags?(value: unknown, options: TestTransactionOptions): Promise<void>;
destroy?(options: TestTransactionOptions): Promise<void>;
}
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<TestEntity, TestEntityData, TestEntityData> {
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/,
);
});