diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index 33e98d1f92..dc706424b1 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -206,6 +206,36 @@ describe('usage analytics organization breakdown', () => { }); }); +describe('usage analytics date range validation', () => { + const invertedFilters = { + ...baseFilters, + startDate: '2026-06-05T00:00:00.000Z', + endDate: '2026-06-04T00:00:00.000Z', + }; + + it('rejects an end date before the start date on the endDate field', () => { + const result = UsageAnalyticsFiltersSchema.safeParse(invertedFilters); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]).toMatchObject({ + path: ['endDate'], + message: 'endDate must not be before startDate', + }); + } + }); + + it('keeps valid ranges accepted, including an empty single-instant window', () => { + expect(UsageAnalyticsFiltersSchema.safeParse(baseFilters).success).toBe(true); + expect( + UsageAnalyticsFiltersSchema.safeParse({ + ...baseFilters, + startDate: '2026-06-04T10:00:00.000Z', + endDate: '2026-06-04T10:00:00.000Z', + }).success + ).toBe(true); + }); +}); + describe('usage analytics procedures', () => { beforeEach(() => { jest.clearAllMocks(); @@ -315,6 +345,24 @@ describe('usage analytics procedures', () => { }); }); + it('rejects an inverted date range with a 400 carrying the specific message', async () => { + await expect( + caller().getSummary({ + ...baseFilters, + startDate: '2026-06-05T00:00:00.000Z', + endDate: '2026-06-04T00:00:00.000Z', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + cause: { + issues: [ + { code: 'custom', path: ['endDate'], message: 'endDate must not be before startDate' }, + ], + }, + }); + expect(mockExecuteSnowflakeStatement).not.toHaveBeenCalled(); + }); + it.each([ () => caller().getSummary(baseFilters), () => caller().getTimeseries({ ...baseFilters, metric: 'cost' }), diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts index 0b9728ed05..8bda3bd09f 100644 --- a/apps/web/src/routers/usage-analytics-schemas.ts +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -66,7 +66,19 @@ const FiltersShape = { excludedProjects: z.array(z.string()).optional(), } as const; -export const UsageAnalyticsFiltersSchema = z.object(FiltersShape); +export const UsageAnalyticsFiltersSchema = z + .object(FiltersShape) + .superRefine((filters, context) => { + // An inverted window cannot match any usage row, so it would otherwise + // return a silently empty result instead of a clear client error. + if (new Date(filters.endDate).getTime() < new Date(filters.startDate).getTime()) { + context.addIssue({ + code: 'custom', + path: ['endDate'], + message: 'endDate must not be before startDate', + }); + } + }); export type UsageAnalyticsFilters = z.infer; export const SummaryOutputSchema = z.object({