583 lines
15 KiB
TypeScript
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/,
|
|
);
|
|
});
|