2026-03-28 18:25:49 +09:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
|
|
|
|
|
const { mockFind, mockSave, mockInsertMany, mockAggregate } = vi.hoisted(() => ({
|
|
|
|
|
mockFind: vi.fn(),
|
|
|
|
|
mockSave: vi.fn(),
|
|
|
|
|
mockInsertMany: vi.fn(),
|
|
|
|
|
mockAggregate: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
vi.mock('../../../src/schemas/cabinet-event.schema.js', () => {
|
2026-03-28 18:25:49 +09:00
|
|
|
const chain = () => ({
|
|
|
|
|
sort: vi.fn().mockReturnThis(),
|
|
|
|
|
limit: vi.fn().mockReturnThis(),
|
|
|
|
|
lean: vi.fn().mockReturnThis(),
|
|
|
|
|
exec: mockFind,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 insertMany = mockInsertMany;
|
|
|
|
|
static aggregate = vi.fn(() => aggChain());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { CabinetEventModel: FakeModel };
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
import { CabinetEventsRepository } from '../../../src/modules/cabinet-events/cabinet-events.repository.js';
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
describe(CabinetEventsRepository.name, () => {
|
|
|
|
|
let repo: CabinetEventsRepository;
|
|
|
|
|
|
|
|
|
|
const baseEventData = {
|
|
|
|
|
householdId: 'hh1',
|
|
|
|
|
userId: 'user-1',
|
|
|
|
|
cabinetItemId: 'ci-1',
|
|
|
|
|
medicineId: 'med-1',
|
|
|
|
|
medicineName: 'Metformin',
|
|
|
|
|
eventType: 'purchased' as const,
|
|
|
|
|
quantity: 10,
|
|
|
|
|
quantityBefore: 0,
|
|
|
|
|
quantityAfter: 10,
|
|
|
|
|
sourceType: 'manual' as const,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
repo = new CabinetEventsRepository();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('create', () => {
|
|
|
|
|
it('creates and returns a cabinet event', async () => {
|
|
|
|
|
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
|
|
|
|
return Promise.resolve(this);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await repo.create(baseEventData);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(baseEventData);
|
|
|
|
|
expect(mockSave).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createMany', () => {
|
|
|
|
|
it('inserts multiple events', async () => {
|
|
|
|
|
const events = [baseEventData, { ...baseEventData, quantity: 5 }];
|
|
|
|
|
mockInsertMany.mockResolvedValue(events);
|
|
|
|
|
|
|
|
|
|
const result = await repo.createMany(events);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(events);
|
|
|
|
|
expect(mockInsertMany).toHaveBeenCalledWith(events);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findByHousehold', () => {
|
|
|
|
|
it('returns paginated events with no filters', async () => {
|
|
|
|
|
const items = [{ _id: 'ev-1', quantity: 10 }];
|
|
|
|
|
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: 'ev-2', quantity: 5 }];
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const cursor = Buffer.from('ev-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: `ev-${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();
|
|
|
|
|
expect(result.pagination.hasMore).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by medicineId', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await repo.findByHousehold('hh1', { medicineId: 'med-1', limit: 20 });
|
|
|
|
|
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by eventType', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await repo.findByHousehold('hh1', { eventType: 'purchased' as never, limit: 20 });
|
|
|
|
|
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by startDate only', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await repo.findByHousehold('hh1', {
|
|
|
|
|
startDate: '2024-01-01T00:00:00.000Z',
|
|
|
|
|
limit: 20,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by endDate only', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await repo.findByHousehold('hh1', {
|
|
|
|
|
endDate: '2024-12-31T00:00:00.000Z',
|
|
|
|
|
limit: 20,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by both startDate and endDate', async () => {
|
|
|
|
|
mockFind.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
await repo.findByHousehold('hh1', {
|
|
|
|
|
startDate: '2024-01-01T00:00:00.000Z',
|
|
|
|
|
endDate: '2024-12-31T00:00:00.000Z',
|
|
|
|
|
limit: 20,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockFind).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findByCabinetItem', () => {
|
|
|
|
|
it('returns paginated events for a cabinet item', async () => {
|
|
|
|
|
const items = [{ _id: 'ev-1', cabinetItemId: 'ci-1' }];
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
|
|
|
|
|
|
|
|
|
|
expect(result.data).toEqual(items);
|
|
|
|
|
expect(result.pagination.hasMore).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles cursor-based pagination', async () => {
|
|
|
|
|
const items = [{ _id: 'ev-2' }];
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const cursor = Buffer.from('ev-1').toString('base64');
|
|
|
|
|
const result = await repo.findByCabinetItem('hh1', 'ci-1', { 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: `ev-${i}` }));
|
|
|
|
|
mockFind.mockResolvedValue(items);
|
|
|
|
|
|
|
|
|
|
const result = await repo.findByCabinetItem('hh1', 'ci-1', { 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.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
|
|
|
|
|
|
|
|
|
|
expect(result.pagination.cursor).toBeNull();
|
|
|
|
|
expect(result.pagination.hasMore).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getSpendingSummary', () => {
|
|
|
|
|
it('returns spending summary with default month period', async () => {
|
|
|
|
|
const byMedicine = [
|
|
|
|
|
{
|
|
|
|
|
_id: 'med-1',
|
|
|
|
|
medicineName: 'Metformin',
|
|
|
|
|
totalSpent: 100,
|
|
|
|
|
totalQuantity: 10,
|
|
|
|
|
avgUnitPrice: 10,
|
|
|
|
|
purchaseCount: 2,
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
|
|
|
|
|
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce(byPeriod);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
|
|
|
|
|
|
|
|
|
expect(result.totalSpent).toBe(100);
|
|
|
|
|
expect(result.currency).toBe('USD');
|
|
|
|
|
expect(result.byMedicine).toHaveLength(1);
|
|
|
|
|
expect(result.byMedicine[0].medicineId).toBe('med-1');
|
|
|
|
|
expect(result.byMedicine[0].medicineName).toBe('Metformin');
|
|
|
|
|
expect(result.byMedicine[0].totalSpent).toBe(100);
|
|
|
|
|
expect(result.byMedicine[0].totalQuantity).toBe(10);
|
|
|
|
|
expect(result.byMedicine[0].avgUnitPrice).toBe(10);
|
|
|
|
|
expect(result.byMedicine[0].purchaseCount).toBe(2);
|
|
|
|
|
expect(result.byPeriod).toHaveLength(1);
|
|
|
|
|
expect(result.byPeriod[0].period).toBe('2024-01');
|
|
|
|
|
expect(result.byPeriod[0].totalSpent).toBe(100);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null currency when no medicine data', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
|
|
|
|
|
|
|
|
|
expect(result.totalSpent).toBe(0);
|
|
|
|
|
expect(result.currency).toBeNull();
|
|
|
|
|
expect(result.byMedicine).toHaveLength(0);
|
|
|
|
|
expect(result.byPeriod).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by medicineId', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
|
|
|
|
|
|
|
|
|
|
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by startDate only', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
await repo.getSpendingSummary('hh1', {
|
|
|
|
|
period: 'month',
|
|
|
|
|
startDate: '2024-01-01T00:00:00.000Z',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by endDate only', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
await repo.getSpendingSummary('hh1', {
|
|
|
|
|
period: 'month',
|
|
|
|
|
endDate: '2024-12-31T00:00:00.000Z',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by both startDate and endDate', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
await repo.getSpendingSummary('hh1', {
|
|
|
|
|
period: 'month',
|
|
|
|
|
startDate: '2024-01-01T00:00:00.000Z',
|
|
|
|
|
endDate: '2024-12-31T00:00:00.000Z',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('uses quarter date format', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
await repo.getSpendingSummary('hh1', { period: 'quarter' });
|
|
|
|
|
|
|
|
|
|
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('uses year date format', async () => {
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
await repo.getSpendingSummary('hh1', { period: 'year' });
|
|
|
|
|
|
|
|
|
|
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles null currency in first medicine entry', async () => {
|
|
|
|
|
const byMedicine = [
|
|
|
|
|
{
|
|
|
|
|
_id: 'med-1',
|
|
|
|
|
medicineName: 'Metformin',
|
|
|
|
|
totalSpent: 50,
|
|
|
|
|
totalQuantity: 5,
|
|
|
|
|
avgUnitPrice: 10,
|
|
|
|
|
purchaseCount: 1,
|
|
|
|
|
currency: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-04-26 18:44:59 +09:00
|
|
|
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce([]);
|
2026-03-28 18:25:49 +09:00
|
|
|
|
|
|
|
|
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
|
|
|
|
|
|
|
|
|
expect(result.currency).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getAvgUnitPriceByMedicine', () => {
|
|
|
|
|
it('returns empty map when no medicine ids provided', async () => {
|
|
|
|
|
const result = await repo.getAvgUnitPriceByMedicine('hh1', []);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual(new Map());
|
|
|
|
|
expect(mockAggregate).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns map of avg unit prices', async () => {
|
|
|
|
|
const results = [
|
|
|
|
|
{ _id: 'med-1', avgUnitPrice: 10.5, currency: 'USD', totalSpent: 105, totalQuantity: 10 },
|
|
|
|
|
{ _id: 'med-2', avgUnitPrice: 5.0, currency: 'EUR', totalSpent: 50, totalQuantity: 10 },
|
|
|
|
|
];
|
|
|
|
|
mockAggregate.mockResolvedValue(results);
|
|
|
|
|
|
|
|
|
|
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1', 'med-2']);
|
|
|
|
|
|
|
|
|
|
expect(map.size).toBe(2);
|
|
|
|
|
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10.5, currency: 'USD' });
|
|
|
|
|
expect(map.get('med-2')).toEqual({ avgUnitPrice: 5.0, currency: 'EUR' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles null currency in results', async () => {
|
|
|
|
|
const results = [
|
|
|
|
|
{ _id: 'med-1', avgUnitPrice: 10, currency: null, totalSpent: 100, totalQuantity: 10 },
|
|
|
|
|
];
|
|
|
|
|
mockAggregate.mockResolvedValue(results);
|
|
|
|
|
|
|
|
|
|
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1']);
|
|
|
|
|
|
|
|
|
|
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10, currency: null });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|