2026-03-28 08:19:48 +09:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
|
|
|
|
|
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave, mockCountDocuments, mockAggregate } =
|
|
|
|
|
vi.hoisted(() => ({
|
|
|
|
|
mockFind: vi.fn(),
|
|
|
|
|
mockFindOne: vi.fn(),
|
|
|
|
|
mockFindOneAndUpdate: vi.fn(),
|
|
|
|
|
mockSave: vi.fn(),
|
|
|
|
|
mockCountDocuments: vi.fn(),
|
|
|
|
|
mockAggregate: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
vi.mock('../../../src/schemas/cabinet-item.schema.js', () => {
|
2026-03-28 08:19:48 +09:00
|
|
|
const chain = () => ({
|
|
|
|
|
sort: vi.fn().mockReturnThis(),
|
|
|
|
|
limit: vi.fn().mockReturnThis(),
|
|
|
|
|
lean: vi.fn().mockReturnThis(),
|
|
|
|
|
exec: mockFind,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const findOneChain = () => ({
|
|
|
|
|
lean: vi.fn().mockReturnThis(),
|
|
|
|
|
exec: mockFindOne,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateChain = () => ({
|
|
|
|
|
exec: mockFindOneAndUpdate,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const countChain = () => ({
|
|
|
|
|
exec: mockCountDocuments,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const aggChain = () => ({
|
|
|
|
|
exec: mockAggregate,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
class FakeModel {
|
|
|
|
|
data: unknown;
|
|
|
|
|
constructor(data: unknown) {
|
|
|
|
|
this.data = data;
|
|
|
|
|
}
|
|
|
|
|
save = mockSave;
|
|
|
|
|
toObject() {
|
|
|
|
|
return this.data;
|
|
|
|
|
}
|
|
|
|
|
static find = vi.fn(() => chain());
|
|
|
|
|
static findOne = vi.fn(() => findOneChain());
|
|
|
|
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
|
|
|
|
static countDocuments = vi.fn(() => countChain());
|
|
|
|
|
static aggregate = vi.fn(() => aggChain());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { CabinetItemModel: FakeModel };
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
import { CabinetRepository } from '../../../src/modules/cabinet/cabinet.repository.js';
|
2026-03-28 08:19:48 +09:00
|
|
|
|
|
|
|
|
describe(CabinetRepository.name, () => {
|
|
|
|
|
let repo: CabinetRepository;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
repo = new CabinetRepository();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findByHousehold', () => {
|
|
|
|
|
it('returns paginated items', async () => {
|
|
|
|
|
const items = [{ _id: 'ci-1', quantity: 30 }];
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
|
|
|
|
|
|
|
|
|
expect(result.data).toEqual(items);
|
|
|
|
|
expect(result.pagination.hasMore).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles cursor-based pagination', async () => {
|
|
|
|
|
const items = [{ _id: 'ci-2', quantity: 10 }];
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const cursor = Buffer.from('ci-1').toString('base64');
|
|
|
|
|
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
|
|
|
|
|
|
|
|
|
expect(result.data).toEqual(items);
|
|
|
|
|
expect(result.pagination.hasMore).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('sets hasMore when more items exist', async () => {
|
|
|
|
|
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ci-${i}`, quantity: i }));
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
|
|
|
|
|
|
|
|
|
expect(result.data).toHaveLength(2);
|
|
|
|
|
expect(result.pagination.hasMore).toBe(true);
|
|
|
|
|
expect(result.pagination.cursor).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null cursor when no data', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
|
|
|
|
expect(result.pagination.cursor).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by medicineId', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
await repo.findByHousehold('hh1', { medicineId: 'med-1', limit: 20 });
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by status', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
await repo.findByHousehold('hh1', {
|
|
|
|
|
status: 'active' as never,
|
|
|
|
|
limit: 20,
|
|
|
|
|
});
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by expiringWithin', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
await repo.findByHousehold('hh1', { expiringWithin: 30, limit: 20 });
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findById', () => {
|
|
|
|
|
it('returns item by id and householdId', async () => {
|
|
|
|
|
const item = { _id: 'ci-1', householdId: 'hh1', quantity: 30 };
|
|
|
|
|
mockFindOne.mockResolvedValue(item);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findById('ci-1', 'hh1');
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(item);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getAggregateSummary', () => {
|
|
|
|
|
it('returns aggregate data', async () => {
|
|
|
|
|
const aggregated = [{ _id: 'med-1', totalQuantity: 60, itemCount: 2 }];
|
|
|
|
|
mockAggregate.mockResolvedValue(aggregated);
|
|
|
|
|
|
|
|
|
|
const result = await repo.getAggregateSummary('hh1');
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(aggregated);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('create', () => {
|
|
|
|
|
it('creates and returns cabinet item', async () => {
|
|
|
|
|
const data = {
|
|
|
|
|
medicineId: 'med-1',
|
|
|
|
|
medicineName: 'Metformin',
|
|
|
|
|
medicineStrength: 500,
|
|
|
|
|
medicineStrengthUnit: 'mg',
|
|
|
|
|
medicineForm: 'tablet',
|
|
|
|
|
quantity: 30,
|
|
|
|
|
unit: 'tablet' as const,
|
|
|
|
|
};
|
|
|
|
|
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
|
|
|
|
return Promise.resolve(this);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await repo.create(data, 'hh1', 'user-1');
|
|
|
|
|
|
|
|
|
|
expect(result).toBeTruthy();
|
|
|
|
|
expect(mockSave).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('update', () => {
|
|
|
|
|
it('updates and returns item', async () => {
|
|
|
|
|
const updated = { _id: 'ci-1', quantity: 25 };
|
|
|
|
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
|
|
|
|
|
|
|
|
|
const result = await repo.update('ci-1', 'hh1', { quantity: 25 });
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(updated);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('adjustQuantity', () => {
|
|
|
|
|
it('adjusts quantity and returns updated item', async () => {
|
|
|
|
|
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 30, status: 'active' });
|
|
|
|
|
const updated = { _id: 'ci-1', quantity: 27, status: 'active' };
|
|
|
|
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
|
|
|
|
|
|
|
|
|
const result = await repo.adjustQuantity('ci-1', 'hh1', -3);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(updated);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('floors quantity at 0 and sets depleted status', async () => {
|
|
|
|
|
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 2, status: 'active' });
|
|
|
|
|
const updated = { _id: 'ci-1', quantity: 0, status: 'depleted' };
|
|
|
|
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
|
|
|
|
|
|
|
|
|
const result = await repo.adjustQuantity('ci-1', 'hh1', -5);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(updated);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('re-activates depleted item when adding stock', async () => {
|
|
|
|
|
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 0, status: 'depleted' });
|
|
|
|
|
const updated = { _id: 'ci-1', quantity: 10, status: 'active' };
|
|
|
|
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
|
|
|
|
|
|
|
|
|
const result = await repo.adjustQuantity('ci-1', 'hh1', 10);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(updated);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null if item not found', async () => {
|
|
|
|
|
mockFindOne.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
const result = await repo.adjustQuantity('ci-missing', 'hh1', 5);
|
|
|
|
|
|
|
|
|
|
expect(result).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findExpiringSoon', () => {
|
|
|
|
|
it('returns items expiring within N days', async () => {
|
|
|
|
|
const items = [{ _id: 'ci-1', expirationDate: new Date() }];
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findExpiringSoon('hh1', 30);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(items);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('countByMedicineId', () => {
|
|
|
|
|
it('returns count', async () => {
|
|
|
|
|
mockCountDocuments.mockResolvedValue(3);
|
|
|
|
|
|
|
|
|
|
const result = await repo.countByMedicineId('med-1');
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('softDelete', () => {
|
|
|
|
|
it('soft deletes and returns item', async () => {
|
|
|
|
|
const deleted = { _id: 'ci-1', isDeleted: true };
|
|
|
|
|
mockFindOneAndUpdate.mockResolvedValue(deleted);
|
|
|
|
|
|
|
|
|
|
const result = await repo.softDelete('ci-1', 'hh1');
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(deleted);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
describe('discard', () => {
|
|
|
|
|
it('zeros quantity, marks depleted and deleted', async () => {
|
|
|
|
|
const discarded = { _id: 'ci-1', quantity: 0, status: 'depleted', isDeleted: true };
|
|
|
|
|
mockFindOneAndUpdate.mockResolvedValue(discarded);
|
|
|
|
|
|
|
|
|
|
const result = await repo.discard('ci-1', 'hh1');
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(discarded);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null when item not found', async () => {
|
|
|
|
|
mockFindOneAndUpdate.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
const result = await repo.discard('ci-missing', 'hh1');
|
|
|
|
|
|
|
|
|
|
expect(result).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findActiveByMedicineForFEFO', () => {
|
|
|
|
|
it('returns active items sorted by expiration date', async () => {
|
|
|
|
|
const items = [
|
|
|
|
|
{ _id: 'ci-1', quantity: 10, expirationDate: new Date('2025-06-01') },
|
|
|
|
|
{ _id: 'ci-2', quantity: 20, expirationDate: new Date('2025-12-01') },
|
|
|
|
|
];
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(items);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns empty array when no active items exist', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
|
|
|
|
|
|
|
|
|
|
expect(result).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-28 08:19:48 +09:00
|
|
|
});
|