Fix dashboard range filter dropping startDate/endDate query params

The aggregations controller typed its query DTO as a `class` with no
class-validator decorators. Because main.ts installs ValidationPipe with
`whitelist: true`, every undecorated property was stripped before
reaching the handler, so all three aggregations endpoints silently fell
back to "current month UTC" no matter what the URL said. Switching to
an `interface` (matching the convention TransactionFilters and
ActivityLogFilters already use) takes the DTO out of class-validator's
metatype reflection and lets the dates through untouched.

Adds a regression test that exercises the real HTTP pipeline via
supertest with the same useGlobalPipes config as main.ts — the existing
direct-call controller tests bypass the pipeline, which is how the bug
shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 11:41:14 -07:00
parent 2ab7c8d97a
commit 0aa2daaee4
2 changed files with 55 additions and 1 deletions
@@ -1,4 +1,6 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AggregationsController } from './aggregations.controller';
import { AggregationsService } from './aggregations.service';
import { AuthGuard } from '../auth/auth.guard';
@@ -114,4 +116,56 @@ describe('AggregationsController', () => {
);
expect(result).toEqual({ inflows: 5000, outflows: 3500, net: 1500 });
});
// Regression: the dashboard's range selector silently fell back to "current
// month" because main.ts installs `ValidationPipe({ whitelist: true })`, and
// a class-typed query DTO with no decorators had every field stripped before
// it reached the handler. The direct-call tests above bypass the HTTP
// pipeline entirely, so the bug shipped. This block goes through the real
// pipeline (supertest + the same useGlobalPipes config as main.ts).
describe('through the full HTTP pipeline', () => {
let app: INestApplication;
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef = await Test.createTestingModule({
controllers: [AggregationsController],
providers: [{ provide: AggregationsService, useValue: mockService }],
})
.overrideGuard(AuthGuard)
.useValue({
canActivate: (ctx: any) => {
ctx.switchToHttp().getRequest().user = mockUser;
return true;
},
})
.compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('preserves startDate/endDate query params end-to-end', async () => {
await request(app.getHttpServer())
.get('/aggregations/summary?startDate=2026-04-01&endDate=2026-04-30')
.expect(200);
expect(mockService.getSummary).toHaveBeenCalledWith(
'user-123',
'2026-04-01',
'2026-04-30',
);
});
});
});
@@ -4,7 +4,7 @@ import { AuthGuard } from '../auth/auth.guard';
import { CurrentUser } from '../auth/user.decorator';
import type { User } from '@prisma/client';
class DateRangeQuery {
interface DateRangeQuery {
startDate: string;
endDate: string;
}