Cover the remaining backend service branches
Pushes backend branch coverage from 80 to 87 by exercising the previously-unhit paths: transactions service notes encryption on create/update, source==destination validation on create and update, not-found and TRANSFER-conversion branches on update, the date-range sub-branches on findAll, and findOne; the advisor's flat / spending-up trend variants, the no-prior-period fallback, the zero-income savings rate, the empty-categories placeholder, and the no-env-var ollama defaults; aggregations.getSummary end-to-end; the date-input fallback and default 365-day window in valuations.list; the encryption interceptor's paginated-response and primitive-passthrough paths plus its no-body and no-matching-fields request paths; the files service's existing-userdir and no-extname paths plus the UPLOAD_DIR fallback; and the activity-log service's open-ended date-range filters. The residual ~3 percent gap to 90 is almost entirely ts-jest decorator-metadata branches on controllers and DTOs, which aren't real code paths and can't be tested away without swapping coverage providers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -181,5 +181,27 @@ describe('ActivityLogService', () => {
|
||||
expect.objectContaining({ skip: 20, take: 10 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies an open-ended range when only startDate is provided', async () => {
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(userId, { startDate: '2026-04-01' });
|
||||
|
||||
const call = mockPrisma.activityLog.findMany.mock.calls[0][0];
|
||||
expect(call.where.createdAt.gte).toEqual(new Date('2026-04-01'));
|
||||
expect(call.where.createdAt.lte).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies an open-ended range when only endDate is provided', async () => {
|
||||
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(userId, { endDate: '2026-04-30' });
|
||||
|
||||
const call = mockPrisma.activityLog.findMany.mock.calls[0][0];
|
||||
expect(call.where.createdAt.lte).toEqual(new Date('2026-04-30'));
|
||||
expect(call.where.createdAt.gte).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,6 +418,125 @@ describe('AdvisorService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('trend line variants', () => {
|
||||
const advisePromptSection = (call: any, label: string) => {
|
||||
const body = JSON.parse(call[1].body);
|
||||
const sys = body.messages.find((m: any) => m.role === 'system');
|
||||
const idx = sys.content.indexOf('Trend:');
|
||||
return sys.content.slice(idx, idx + 200);
|
||||
};
|
||||
|
||||
it('reports rising spending when this period spent more than the previous period', async () => {
|
||||
mockAggregations.getSummary
|
||||
.mockResolvedValueOnce({
|
||||
netWorth: 0,
|
||||
totalDebt: 0,
|
||||
income: 5000,
|
||||
expense: 4000,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
netWorth: 0,
|
||||
totalDebt: 0,
|
||||
income: 5000,
|
||||
expense: 3000,
|
||||
});
|
||||
mockOllamaOnce('opening');
|
||||
|
||||
await service.getAdvice(userId);
|
||||
|
||||
const trend = advisePromptSection(mockFetch.mock.calls[0], 'Trend');
|
||||
expect(trend).toMatch(/spending is up/i);
|
||||
});
|
||||
|
||||
it('reports flat spending when current and previous expenses match', async () => {
|
||||
mockAggregations.getSummary.mockResolvedValue({
|
||||
netWorth: 0,
|
||||
totalDebt: 0,
|
||||
income: 5000,
|
||||
expense: 3000,
|
||||
});
|
||||
mockOllamaOnce('opening');
|
||||
|
||||
await service.getAdvice(userId);
|
||||
|
||||
const trend = advisePromptSection(mockFetch.mock.calls[0], 'Trend');
|
||||
expect(trend).toMatch(/spending is flat/i);
|
||||
});
|
||||
|
||||
it('says there is no prior-period data when previous expense is zero', async () => {
|
||||
mockAggregations.getSummary
|
||||
.mockResolvedValueOnce({
|
||||
netWorth: 0,
|
||||
totalDebt: 0,
|
||||
income: 5000,
|
||||
expense: 100,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
netWorth: 0,
|
||||
totalDebt: 0,
|
||||
income: 0,
|
||||
expense: 0,
|
||||
});
|
||||
mockOllamaOnce('opening');
|
||||
|
||||
await service.getAdvice(userId);
|
||||
|
||||
const trend = advisePromptSection(mockFetch.mock.calls[0], 'Trend');
|
||||
expect(trend).toMatch(/no prior-period data/i);
|
||||
});
|
||||
|
||||
it('renders 0.0% savings rate when there is no income', async () => {
|
||||
mockAggregations.getSummary.mockResolvedValue({
|
||||
netWorth: 0,
|
||||
totalDebt: 0,
|
||||
income: 0,
|
||||
expense: 50,
|
||||
});
|
||||
mockOllamaOnce('opening');
|
||||
|
||||
await service.getAdvice(userId);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
const sys = body.messages.find((m: any) => m.role === 'system');
|
||||
expect(sys.content).toMatch(/savings rate: 0\.0%/i);
|
||||
});
|
||||
|
||||
it('renders "(none yet)" placeholder when there are no top categories', async () => {
|
||||
mockAggregations.getSpendingByCategory.mockResolvedValue([]);
|
||||
mockOllamaOnce('opening');
|
||||
|
||||
await service.getAdvice(userId);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
const sys = body.messages.find((m: any) => m.role === 'system');
|
||||
expect(sys.content).toMatch(/\(none yet\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration fallbacks', () => {
|
||||
it('falls back to localhost ollama and llama3 when no env vars are set', async () => {
|
||||
const fallbackConfig = { get: jest.fn(() => undefined) };
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AdvisorService,
|
||||
{ provide: AggregationsService, useValue: mockAggregations },
|
||||
{ provide: ConfigService, useValue: fallbackConfig },
|
||||
],
|
||||
}).compile();
|
||||
const fallbackService = module.get<AdvisorService>(AdvisorService);
|
||||
|
||||
mockOllamaOnce('opening');
|
||||
await fallbackService.chat(userId, []);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/chat',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.model).toBe('llama3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('system prompt anti-math hardening', () => {
|
||||
it('puts the no-math rule near the top of the prompt', async () => {
|
||||
mockOllamaOnce('opening analysis');
|
||||
|
||||
@@ -336,6 +336,35 @@ describe('AggregationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary', () => {
|
||||
it('returns the merged net worth, total debt, and income/expense totals', async () => {
|
||||
mockPrisma.account.aggregate
|
||||
// getNetWorth — assets
|
||||
.mockResolvedValueOnce({ _sum: { balance: 12000 } })
|
||||
// getNetWorth — liabilities
|
||||
.mockResolvedValueOnce({ _sum: { balance: 500 } })
|
||||
// getTotalDebt
|
||||
.mockResolvedValueOnce({ _sum: { balance: 500 } });
|
||||
mockPrisma.transaction.groupBy.mockResolvedValue([
|
||||
{ type: 'INCOME', _sum: { amount: 5000 } },
|
||||
{ type: 'EXPENSE', _sum: { amount: 3200 } },
|
||||
]);
|
||||
|
||||
const result = await service.getSummary(
|
||||
userId,
|
||||
'2026-04-01',
|
||||
'2026-04-30',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
netWorth: 11500,
|
||||
totalDebt: 500,
|
||||
income: 5000,
|
||||
expense: 3200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountBalanceHistory', () => {
|
||||
it('throws NotFound if the account is not owned by the user', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(null);
|
||||
|
||||
@@ -91,4 +91,55 @@ describe('EncryptionInterceptor', () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('decrypts the data array inside a paginated response envelope', (done) => {
|
||||
const context = createMockContext({});
|
||||
const handler = createMockHandler({
|
||||
data: [
|
||||
{ id: '1', accountNumber: 'encrypted_AAAA' },
|
||||
{ id: '2', accountNumber: 'encrypted_BBBB' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
interceptor.intercept(context, handler).subscribe((result) => {
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.data[0].accountNumber).toBe('AAAA');
|
||||
expect(result.data[1].accountNumber).toBe('BBBB');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through primitive responses untouched', (done) => {
|
||||
const context = createMockContext({});
|
||||
const handler = createMockHandler('plain string');
|
||||
|
||||
interceptor.intercept(context, handler).subscribe((result) => {
|
||||
expect(result).toBe('plain string');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('skips request encryption when there is no body', (done) => {
|
||||
const context = createMockContext(undefined);
|
||||
const handler = createMockHandler({ id: '1' });
|
||||
|
||||
interceptor.intercept(context, handler).subscribe(() => {
|
||||
expect(mockEncryptionService.encryptField).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('only encrypts fields that are actually present on the request body', (done) => {
|
||||
const context = createMockContext({ name: 'no secrets here' });
|
||||
const handler = createMockHandler({ id: '1' });
|
||||
|
||||
interceptor.intercept(context, handler).subscribe(() => {
|
||||
expect(mockEncryptionService.encryptField).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,55 @@ describe('FilesService', () => {
|
||||
const result = service.saveFile('user-123', file);
|
||||
expect(result).toMatch(/\.pdf$/);
|
||||
});
|
||||
|
||||
it('skips mkdirSync when the user upload dir already exists', () => {
|
||||
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const file = {
|
||||
originalname: 'receipt.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 1024,
|
||||
buffer: Buffer.from('x'),
|
||||
} as Express.Multer.File;
|
||||
|
||||
service.saveFile('user-123', file);
|
||||
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the .bin extension when the file has no extname', () => {
|
||||
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const file = {
|
||||
originalname: 'noext',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 1024,
|
||||
buffer: Buffer.from('x'),
|
||||
} as Express.Multer.File;
|
||||
|
||||
const result = service.saveFile('user-123', file);
|
||||
expect(result).toMatch(/\.bin$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDir fallback', () => {
|
||||
it('defaults to ./uploads when UPLOAD_DIR is not configured', async () => {
|
||||
const fallbackModule: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FilesService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { get: jest.fn().mockReturnValue(undefined) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
const fallbackService = fallbackModule.get<FilesService>(FilesService);
|
||||
|
||||
const fullPath = fallbackService.getFilePath('receipts/u/x.jpg');
|
||||
// path.join collapses './uploads' to 'uploads' so just look for the segment.
|
||||
expect(fullPath).toContain('uploads');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
||||
@@ -244,6 +244,46 @@ describe('TransactionsService', () => {
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('encrypts notes on create when provided', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
txClient.transaction.create.mockResolvedValue({
|
||||
...baseTxn,
|
||||
notes: 'enc:secret',
|
||||
});
|
||||
|
||||
await service.create(userId, {
|
||||
accountId: 'acc-1',
|
||||
amount: 10,
|
||||
type: TransactionType.EXPENSE,
|
||||
description: 'Coffee',
|
||||
date: '2026-04-01',
|
||||
notes: 'private memo',
|
||||
});
|
||||
|
||||
expect(mockEncryption.encryptField).toHaveBeenCalledWith('private memo');
|
||||
const created = txClient.transaction.create.mock.calls[0][0];
|
||||
// The data passed to Prisma should carry the encrypted (mocked passthrough) value.
|
||||
expect(created.data.notes).toBe('private memo');
|
||||
});
|
||||
|
||||
it('rejects a TRANSFER whose source and destination are the same account', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
await expect(
|
||||
service.create(userId, {
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: 'acc-1',
|
||||
amount: 100,
|
||||
type: TransactionType.TRANSFER,
|
||||
description: 'self transfer',
|
||||
date: '2026-04-01',
|
||||
}),
|
||||
).rejects.toThrow(/must differ/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
@@ -254,6 +294,68 @@ describe('TransactionsService', () => {
|
||||
const where = mockPrisma.transaction.findMany.mock.calls[0][0].where;
|
||||
expect(where.OR).toEqual([{ accountId: 'acc-1' }, { destinationAccountId: 'acc-1' }]);
|
||||
});
|
||||
|
||||
it('applies a date range when both startDate and endDate are provided', async () => {
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
mockPrisma.transaction.count.mockResolvedValue(0);
|
||||
await service.findAll(userId, {
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
});
|
||||
const where = mockPrisma.transaction.findMany.mock.calls[0][0].where;
|
||||
expect(where.date.gte).toBeInstanceOf(Date);
|
||||
expect(where.date.lte).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('applies an open-ended date range when only startDate is provided', async () => {
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
mockPrisma.transaction.count.mockResolvedValue(0);
|
||||
await service.findAll(userId, { startDate: '2026-04-01' });
|
||||
const where = mockPrisma.transaction.findMany.mock.calls[0][0].where;
|
||||
expect(where.date.gte).toBeInstanceOf(Date);
|
||||
expect(where.date.lte).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies an open-ended date range when only endDate is provided', async () => {
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
mockPrisma.transaction.count.mockResolvedValue(0);
|
||||
await service.findAll(userId, { endDate: '2026-04-30' });
|
||||
const where = mockPrisma.transaction.findMany.mock.calls[0][0].where;
|
||||
expect(where.date.lte).toBeInstanceOf(Date);
|
||||
expect(where.date.gte).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters by categoryId and type when supplied', async () => {
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
mockPrisma.transaction.count.mockResolvedValue(0);
|
||||
await service.findAll(userId, {
|
||||
categoryId: 'cat-1',
|
||||
type: TransactionType.EXPENSE,
|
||||
});
|
||||
const where = mockPrisma.transaction.findMany.mock.calls[0][0].where;
|
||||
expect(where.categoryId).toBe('cat-1');
|
||||
expect(where.type).toBe('EXPENSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('returns the decrypted transaction when found', async () => {
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue({
|
||||
...baseTxn,
|
||||
notes: 'enc:secret',
|
||||
});
|
||||
const result = await service.findOne(userId, 'txn-1');
|
||||
expect(result.id).toBe('txn-1');
|
||||
// Mock passthrough means we still see 'enc:secret', but decryptField must have been called.
|
||||
expect(mockEncryption.decryptField).toHaveBeenCalledWith('enc:secret');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when the transaction does not exist', async () => {
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue(null);
|
||||
await expect(service.findOne(userId, 'missing')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
@@ -329,6 +431,58 @@ describe('TransactionsService', () => {
|
||||
|
||||
expect(txClient.account.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when updating a transaction that does not exist', async () => {
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.update(userId, 'missing', { description: 'x' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('encrypts notes on update when the field is supplied', async () => {
|
||||
const existing = { ...baseTxn, type: TransactionType.EXPENSE, amount: 100 };
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue(existing);
|
||||
txClient.transaction.update.mockResolvedValue({ ...existing, notes: 'new memo' });
|
||||
|
||||
await service.update(userId, 'txn-1', { notes: 'new memo' } as any);
|
||||
|
||||
expect(mockEncryption.encryptField).toHaveBeenCalledWith('new memo');
|
||||
});
|
||||
|
||||
it('throws BadRequestException when an update would convert to TRANSFER without a destination', async () => {
|
||||
const existing = { ...baseTxn, type: TransactionType.EXPENSE, amount: 100 };
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue(existing);
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.update(userId, 'txn-1', {
|
||||
type: TransactionType.TRANSFER,
|
||||
} as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when an updated TRANSFER points back at its source', async () => {
|
||||
const existing = {
|
||||
...baseTxn,
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: 'acc-2',
|
||||
amount: 50,
|
||||
type: TransactionType.TRANSFER,
|
||||
};
|
||||
mockPrisma.transaction.findFirst.mockResolvedValue(existing);
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
{ id: 'acc-2', type: AccountType.SAVINGS },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.update(userId, 'txn-1', {
|
||||
destinationAccountId: 'acc-1',
|
||||
} as any),
|
||||
).rejects.toThrow(/must differ/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
|
||||
@@ -118,6 +118,23 @@ describe('ValuationsService', () => {
|
||||
const stored: Date = call.data.date;
|
||||
expect(stored.toISOString()).toBe('2026-04-17T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('falls back to native Date parsing for an unparseable date string', async () => {
|
||||
// The "YYYY-MM-DD" fast path can't extract components → falls through to
|
||||
// `new Date(dateStr)`. The exact Date is implementation-defined, but the
|
||||
// call must still succeed.
|
||||
mockPrisma.account.findFirst.mockResolvedValue({ id: accountId });
|
||||
mockPrisma.accountValuation.create.mockResolvedValue({});
|
||||
mockPrisma.accountValuation.findFirst.mockResolvedValue(null);
|
||||
|
||||
await service.create(userId, accountId, {
|
||||
date: 'not-a-date' as any,
|
||||
value: 100,
|
||||
});
|
||||
|
||||
const call = mockPrisma.accountValuation.create.mock.calls[0][0];
|
||||
expect(call.data.date).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
@@ -140,6 +157,23 @@ describe('ValuationsService', () => {
|
||||
expect(result).toEqual(rows);
|
||||
});
|
||||
|
||||
it('uses the default 365-day window when days is omitted', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue({ id: accountId });
|
||||
mockPrisma.accountValuation.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.list(userId, accountId);
|
||||
|
||||
const call = mockPrisma.accountValuation.findMany.mock.calls[0][0];
|
||||
const cutoff: Date = call.where.date.gte;
|
||||
const daysAgo =
|
||||
(Date.now() - cutoff.getTime()) / (24 * 60 * 60 * 1000);
|
||||
// Allow a small tolerance for clock drift between the default
|
||||
// computation and the assertion. Strict equality would race with the
|
||||
// system clock.
|
||||
expect(daysAgo).toBeGreaterThan(364);
|
||||
expect(daysAgo).toBeLessThan(366);
|
||||
});
|
||||
|
||||
it('rejects an account not owned by the user', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(null);
|
||||
await expect(service.list(userId, 'stranger', 30)).rejects.toThrow(NotFoundException);
|
||||
|
||||
Reference in New Issue
Block a user