Add customers management module with database schema and validation
- Introduced `CustomersModule` to manage customer data, including read and write controllers. - Created database migrations for the `customers` and `customer_contacts` tables, including constraints and unique indexes. - Implemented validation for customer fields such as name, code, and address with corresponding utility functions. - Developed service and repository layers for handling customer data operations. - Added unit tests for the customers service, repository, and controllers to ensure functionality and correctness. - Updated application module to include the new `CustomersModule` for better organization.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE } from '../../../database/database.module';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
|
||||
describe('CustomersRepository', () => {
|
||||
let repository: CustomersRepository;
|
||||
|
||||
const limit = jest.fn();
|
||||
const orderBy = jest.fn();
|
||||
const offset = jest.fn();
|
||||
const where = jest.fn();
|
||||
const from = jest.fn();
|
||||
const select = jest.fn();
|
||||
const returning = jest.fn();
|
||||
const values = jest.fn();
|
||||
const insert = jest.fn();
|
||||
const set = jest.fn();
|
||||
const update = jest.fn();
|
||||
const del = jest.fn();
|
||||
const transaction = jest.fn();
|
||||
const $dynamic = jest.fn();
|
||||
|
||||
const db = {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
delete: del,
|
||||
transaction,
|
||||
};
|
||||
|
||||
const row = {
|
||||
id: 'cu-1',
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: '+6281234567890',
|
||||
address: 'Jl Sudirman No 1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
nfcId: 'NFC-001',
|
||||
status: 'draft',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const contactRow = {
|
||||
id: 'ct-1',
|
||||
customerId: 'cu-1',
|
||||
name: 'Jean Luc',
|
||||
jobTitle: 'Buyer',
|
||||
phone: '+6281234567891',
|
||||
mobilePhone: null,
|
||||
notes: 'Primary',
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: PhoneNumber.create('+6281234567890'),
|
||||
address: 'Jl Sudirman No 1',
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({ offset }));
|
||||
offset.mockResolvedValue([row]);
|
||||
from.mockImplementation(() => ({
|
||||
where,
|
||||
$dynamic,
|
||||
}));
|
||||
$dynamic.mockReturnValue({ where });
|
||||
select.mockImplementation(() => ({ from }));
|
||||
values.mockReturnValue({ returning });
|
||||
insert.mockReturnValue({ values });
|
||||
set.mockReturnValue({ where });
|
||||
update.mockReturnValue({ set });
|
||||
del.mockReturnValue({ where });
|
||||
returning.mockResolvedValue([row]);
|
||||
transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db));
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [CustomersRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(CustomersRepository);
|
||||
});
|
||||
|
||||
it('findById maps a row and contacts to domain', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
orderBy.mockResolvedValueOnce([contactRow]);
|
||||
const customer = await repository.findById('cu-1');
|
||||
expect(customer).toMatchObject({
|
||||
id: 'cu-1',
|
||||
code: 'CUST_01',
|
||||
createdBy: 'user-1',
|
||||
});
|
||||
expect(customer?.phone.value).toBe('+6281234567890');
|
||||
expect(customer?.status.value).toBe('draft');
|
||||
expect(customer?.contacts[0].name).toBe('Jean Luc');
|
||||
expect(customer?.contacts[0].phone?.value).toBe('+6281234567891');
|
||||
});
|
||||
|
||||
it('findById returns null when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(repository.findById('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('list returns mapped rows and total without loading contacts', async () => {
|
||||
select
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve([{ total: 1 }]),
|
||||
}),
|
||||
}))
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
$dynamic: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => ({
|
||||
limit: () => ({
|
||||
offset: () => Promise.resolve([row]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await repository.list({
|
||||
name: 'Acme',
|
||||
code: 'CUST',
|
||||
search: 'sudirman',
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0].code).toBe('CUST_01');
|
||||
expect(result.data[0].contacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('create inserts and maps unique violations', async () => {
|
||||
returning.mockResolvedValue([row]);
|
||||
orderBy.mockResolvedValueOnce([]);
|
||||
const created = await repository.create(createInput);
|
||||
expect(created.code).toBe('CUST_01');
|
||||
|
||||
transaction.mockRejectedValueOnce({ code: '23505' });
|
||||
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
transaction.mockRejectedValueOnce({
|
||||
code: '23505',
|
||||
constraint: 'customers_nfc_id_unique',
|
||||
});
|
||||
await expect(repository.create(createInput)).rejects.toMatchObject({
|
||||
message: 'Customer NFC ID already exists',
|
||||
});
|
||||
|
||||
transaction.mockRejectedValueOnce({
|
||||
cause: {
|
||||
code: '23505',
|
||||
constraint_name: 'customers_code_unique',
|
||||
},
|
||||
});
|
||||
await expect(repository.create(createInput)).rejects.toMatchObject({
|
||||
message: 'Customer code already exists',
|
||||
});
|
||||
});
|
||||
|
||||
it('create rethrows unknown errors', async () => {
|
||||
transaction.mockRejectedValue(new Error('db down'));
|
||||
await expect(repository.create(createInput)).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('createMany returns 0 for an empty batch', async () => {
|
||||
await expect(repository.createMany([])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('update throws when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.update('missing', { userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('updateStatus throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.updateStatus('missing', Status.create('active'), 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('delete throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(repository.delete('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
|
||||
await expect(
|
||||
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
|
||||
).resolves.toBe(0);
|
||||
await expect(repository.bulkDelete([])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('addContact throws when customer is missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.addContact('missing', { name: 'Ada' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deleteContact throws when contact is missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.deleteContact('cu-1', 'ct-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('extendListQuery is a passthrough hook', () => {
|
||||
const qb = { join: true };
|
||||
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user