1- import { NotFoundException } from "@nestjs/common" ;
1+ import { ForbiddenException , NotFoundException } from "@nestjs/common" ;
22import { Test , TestingModule } from "@nestjs/testing" ;
33import { PrismaService } from "@/database/prisma.service" ;
4+ import { UsageEventType } from "@/generated" ;
45import { UsageQueryService } from "./usage-query.service" ;
56
67const mockPrisma = {
@@ -14,6 +15,29 @@ const mockPrisma = {
1415 } ,
1516 usageEvent : {
1617 findMany : jest . fn ( ) ,
18+ findFirst : jest . fn ( ) . mockResolvedValue ( {
19+ rate_version : {
20+ id : "123" ,
21+ created_at : new Date ( ) ,
22+ version : "1.0.0" ,
23+ effective_from : new Date ( ) ,
24+ unit_cost_dollars : 0.001 ,
25+ units_per_gb_per_month : 0.1 ,
26+ max_pages_assumption : 10 ,
27+ max_array_items_assumption : 3 ,
28+ } ,
29+ id : "abc" ,
30+ group_id : "group-1" ,
31+ created_at : new Date ( ) ,
32+ event_type : UsageEventType . activity_completed ,
33+ workflow_execution_id : "abc-123" ,
34+ workflow_version_id : "1.0.0" ,
35+ activity_name : "azureOcr.submit" ,
36+ metered_quantity : 3 ,
37+ units_consumed : 2 ,
38+ unit_cost_dollars : 3 ,
39+ rate_version_id : "132112" ,
40+ } ) ,
1741 } ,
1842 group : {
1943 findMany : jest . fn ( ) ,
@@ -30,7 +54,7 @@ const mockPrisma = {
3054const mockPrismaService = { prisma : mockPrisma } ;
3155
3256function makeDecimal ( n : number ) {
33- return { toNumber : ( ) => n } ;
57+ return { toNumber : ( ) => n , equals : ( v : number ) => n === v } ;
3458}
3559
3660describe ( "UsageQueryService" , ( ) => {
@@ -146,82 +170,267 @@ describe("UsageQueryService", () => {
146170 // ---------------------------------------------------------------------------
147171 // getRunDetail
148172 // ---------------------------------------------------------------------------
149- // describe("getRunDetail", () => {
150- // it("throws NotFoundException when no events exist for the execution", async () => {
151- // mockPrisma.usageEvent.findMany.mockResolvedValue([]);
152- // await expect(service.getRunDetail("group-1", "exec-abc")).rejects.toThrow(
153- // NotFoundException,
154- // );
155- // });
156-
157- // it("throws ForbiddenException when execution belongs to a different group", async () => {
158- // mockPrisma.usageEvent.findMany.mockResolvedValue([
159- // {
160- // id: "evt-1",
161- // group_id: "group-other",
162- // event_type: "workflow_started",
163- // activity_name: null,
164- // units_consumed: makeDecimal(0),
165- // estimated_units: null,
166- // metered_quantity: null,
167- // created_at: new Date(),
168- // rate_version: { unit_cost_dollars: makeDecimal(0.001) },
169- // },
170- // ]);
171-
172- // await expect(service.getRunDetail("group-1", "exec-abc")).rejects.toThrow(
173- // ForbiddenException,
174- // );
175- // });
176-
177- // it("returns correct run detail with dollar values computed", async () => {
178- // const createdAt = new Date("2026-06-01T10:00:00Z");
179- // mockPrisma.usageEvent.findMany.mockResolvedValue([
180- // {
181- // id: "evt-1",
182- // group_id: "group-1",
183- // event_type: "workflow_started",
184- // activity_name: null,
185- // units_consumed: makeDecimal(0),
186- // estimated_units: makeDecimal(50),
187- // metered_quantity: null,
188- // created_at: createdAt,
189- // rate_version: { unit_cost_dollars: makeDecimal(0.001) },
190- // },
191- // {
192- // id: "evt-2",
193- // group_id: "group-1",
194- // event_type: "activity_completed",
195- // activity_name: "azureOcr.extract",
196- // units_consumed: makeDecimal(40),
197- // estimated_units: null,
198- // metered_quantity: 4,
199- // created_at: createdAt,
200- // rate_version: { unit_cost_dollars: makeDecimal(0.001) },
201- // },
202- // {
203- // id: "evt-3",
204- // group_id: "group-1",
205- // event_type: "workflow_completed",
206- // activity_name: null,
207- // units_consumed: makeDecimal(40),
208- // estimated_units: null,
209- // metered_quantity: null,
210- // created_at: createdAt,
211- // rate_version: { unit_cost_dollars: makeDecimal(0.001) },
212- // },
213- // ]);
214-
215- // const result = await service.getRunDetail("group-1", "exec-abc");
216-
217- // expect(result.workflow_execution_id).toBe("exec-abc");
218- // expect(result.estimated_units).toBe(50);
219- // expect(result.total_units_consumed).toBe(40);
220- // expect(result.events).toHaveLength(3);
221- // expect(result.events[1].dollar_value).toBe(0.04); // 40 * 0.001
222- // expect(result.events[1].metered_quantity).toBe(4);
223- // });
224- // });
173+ describe ( "getRunDetail" , ( ) => {
174+ it ( "throws NotFoundException when no events exist for the execution" , async ( ) => {
175+ mockPrisma . usageEvent . findFirst . mockResolvedValue ( null ) ;
176+ await expect ( service . getRunDetail ( "group-1" , "exec-abc" ) ) . rejects . toThrow (
177+ NotFoundException ,
178+ ) ;
179+ } ) ;
180+
181+ it ( "throws ForbiddenException when execution belongs to a different group" , async ( ) => {
182+ const createdAt = new Date ( "2026-06-01T10:00:00Z" ) ;
183+ mockPrisma . usageEvent . findFirst . mockResolvedValue ( {
184+ id : "evt-1" ,
185+ group_id : "group-other" ,
186+ event_type : "workflow_cost" ,
187+ activity_name : null ,
188+ units_consumed : makeDecimal ( 10 ) ,
189+ estimated_units : null ,
190+ metered_quantity : null ,
191+ created_at : createdAt ,
192+ workflow_version_id : "v1" ,
193+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
194+ } ) ;
195+
196+ await expect ( service . getRunDetail ( "group-1" , "exec-abc" ) ) . rejects . toThrow (
197+ ForbiddenException ,
198+ ) ;
199+ } ) ;
200+
201+ it ( "returns correct run detail with dollar values computed" , async ( ) => {
202+ const createdAt = new Date ( "2026-06-01T10:00:00Z" ) ;
203+ mockPrisma . usageEvent . findFirst . mockResolvedValue ( {
204+ id : "evt-2" ,
205+ group_id : "group-1" ,
206+ event_type : "workflow_cost" ,
207+ activity_name : null ,
208+ units_consumed : makeDecimal ( 40 ) ,
209+ estimated_units : makeDecimal ( 50 ) ,
210+ metered_quantity : 4 ,
211+ created_at : createdAt ,
212+ workflow_version_id : "v-1" ,
213+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
214+ } ) ;
215+
216+ const result = await service . getRunDetail ( "group-1" , "exec-abc" ) ;
217+
218+ expect ( result . workflow_execution_id ) . toBe ( "exec-abc" ) ;
219+ expect ( result . group_id ) . toBe ( "group-1" ) ;
220+ expect ( result . estimated_units ) . toBe ( 50 ) ;
221+ expect ( result . total_units_consumed ) . toBe ( 40 ) ;
222+ expect ( result . units_consumed ) . toBe ( 40 ) ;
223+ expect ( result . dollar_value ) . toBeCloseTo ( 0.04 ) ;
224+ expect ( result . workflow_version_id ) . toBe ( "v-1" ) ;
225+ } ) ;
226+
227+ it ( "returns null estimated_units when event has no estimated_units" , async ( ) => {
228+ const createdAt = new Date ( "2026-06-01T10:00:00Z" ) ;
229+ mockPrisma . usageEvent . findFirst . mockResolvedValue ( {
230+ id : "evt-3" ,
231+ group_id : "group-1" ,
232+ event_type : "workflow_cost" ,
233+ activity_name : null ,
234+ units_consumed : makeDecimal ( 5 ) ,
235+ estimated_units : null ,
236+ metered_quantity : null ,
237+ created_at : createdAt ,
238+ workflow_version_id : "v-2" ,
239+ rate_version : { unit_cost_dollars : makeDecimal ( 0.01 ) } ,
240+ } ) ;
241+
242+ const result = await service . getRunDetail ( "group-1" , "exec-abc" ) ;
243+ expect ( result . estimated_units ) . toBeNull ( ) ;
244+ expect ( result . workflow_version_id ) . toBe ( "v-2" ) ;
245+ } ) ;
246+ } ) ;
247+
248+ // ---------------------------------------------------------------------------
249+ // getGroupActivityHistory
250+ // ---------------------------------------------------------------------------
251+ describe ( "getGroupActivityHistory" , ( ) => {
252+ it ( "returns empty array when no events exist" , async ( ) => {
253+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [ ] ) ;
254+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
255+ expect ( result ) . toEqual ( [ ] ) ;
256+ } ) ;
257+
258+ it ( "skips events with zero units_consumed" , async ( ) => {
259+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [
260+ {
261+ activity_name : "azureOcr.extract" ,
262+ units_consumed : makeDecimal ( 0 ) ,
263+ created_at : new Date ( "2026-06-15T00:00:00Z" ) ,
264+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
265+ event_type : "activity_completed" ,
266+ workflow_version_id : "v1" ,
267+ } ,
268+ ] ) ;
269+
270+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
271+ expect ( result ) . toEqual ( [ ] ) ;
272+ } ) ;
273+
274+ it ( "groups a single activity_completed event by activity_name" , async ( ) => {
275+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [
276+ {
277+ activity_name : "azureOcr.extract" ,
278+ units_consumed : makeDecimal ( 10 ) ,
279+ created_at : new Date ( "2026-06-15T00:00:00Z" ) ,
280+ rate_version : { unit_cost_dollars : makeDecimal ( 0.002 ) } ,
281+ event_type : "activity_completed" ,
282+ workflow_version_id : "v1" ,
283+ } ,
284+ ] ) ;
285+
286+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
287+ expect ( result ) . toHaveLength ( 1 ) ;
288+ expect ( result [ 0 ] . period_year ) . toBe ( 2026 ) ;
289+ expect ( result [ 0 ] . period_month ) . toBe ( 6 ) ;
290+ expect ( result [ 0 ] . event_type ) . toBe ( "activity_completed" ) ;
291+ expect ( result [ 0 ] . units_consumed ) . toBe ( 10 ) ;
292+ expect ( result [ 0 ] . dollars_spent ) . toBeCloseTo ( 0.02 ) ;
293+ expect ( result [ 0 ] . activities [ "azureOcr.extract" ] ) . toEqual ( {
294+ units_consumed : 10 ,
295+ dollars_spent : expect . closeTo ( 0.02 ) ,
296+ } ) ;
297+ } ) ;
298+
299+ it ( "uses workflow_version_id as activity key for workflow_cost events" , async ( ) => {
300+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [
301+ {
302+ activity_name : null ,
303+ units_consumed : makeDecimal ( 20 ) ,
304+ created_at : new Date ( "2026-06-15T00:00:00Z" ) ,
305+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
306+ event_type : "workflow_cost" ,
307+ workflow_version_id : "workflow-v2" ,
308+ } ,
309+ ] ) ;
310+
311+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
312+ expect ( result ) . toHaveLength ( 1 ) ;
313+ expect ( result [ 0 ] . event_type ) . toBe ( "workflow_cost" ) ;
314+ expect ( result [ 0 ] . activities [ "workflow-v2" ] ) . toBeDefined ( ) ;
315+ expect ( result [ 0 ] . activities [ "workflow-v2" ] . units_consumed ) . toBe ( 20 ) ;
316+ } ) ;
317+
318+ it ( "falls back to 'other' when activity_name is null for non-workflow_cost events" , async ( ) => {
319+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [
320+ {
321+ activity_name : null ,
322+ units_consumed : makeDecimal ( 5 ) ,
323+ created_at : new Date ( "2026-06-15T00:00:00Z" ) ,
324+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
325+ event_type : "activity_completed" ,
326+ workflow_version_id : null ,
327+ } ,
328+ ] ) ;
329+
330+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
331+ expect ( result [ 0 ] . activities [ "other" ] ) . toBeDefined ( ) ;
332+ expect ( result [ 0 ] . activities [ "other" ] . units_consumed ) . toBe ( 5 ) ;
333+ } ) ;
334+
335+ it ( "aggregates multiple events in the same bucket" , async ( ) => {
336+ const date = new Date ( "2026-06-15T00:00:00Z" ) ;
337+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [
338+ {
339+ activity_name : "azureOcr.extract" ,
340+ units_consumed : makeDecimal ( 10 ) ,
341+ created_at : date ,
342+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
343+ event_type : "activity_completed" ,
344+ workflow_version_id : "v1" ,
345+ } ,
346+ {
347+ activity_name : "azureOcr.extract" ,
348+ units_consumed : makeDecimal ( 5 ) ,
349+ created_at : date ,
350+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
351+ event_type : "activity_completed" ,
352+ workflow_version_id : "v1" ,
353+ } ,
354+ ] ) ;
355+
356+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
357+ expect ( result ) . toHaveLength ( 1 ) ;
358+ expect ( result [ 0 ] . units_consumed ) . toBe ( 15 ) ;
359+ expect ( result [ 0 ] . activities [ "azureOcr.extract" ] . units_consumed ) . toBe ( 15 ) ;
360+ } ) ;
361+
362+ it ( "produces separate buckets for different activity types in same month" , async ( ) => {
363+ const date = new Date ( "2026-06-15T00:00:00Z" ) ;
364+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [
365+ {
366+ activity_name : "azureOcr.extract" ,
367+ units_consumed : makeDecimal ( 10 ) ,
368+ created_at : date ,
369+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
370+ event_type : "activity_completed" ,
371+ workflow_version_id : "v1" ,
372+ } ,
373+ {
374+ activity_name : "formRecognizer.analyze" ,
375+ units_consumed : makeDecimal ( 8 ) ,
376+ created_at : date ,
377+ rate_version : { unit_cost_dollars : makeDecimal ( 0.002 ) } ,
378+ event_type : "activity_completed" ,
379+ workflow_version_id : "v1" ,
380+ } ,
381+ ] ) ;
382+
383+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
384+ // Same event_type + workflow_version_id → same bucket
385+ expect ( result ) . toHaveLength ( 1 ) ;
386+ expect ( result [ 0 ] . units_consumed ) . toBe ( 18 ) ;
387+ expect ( result [ 0 ] . activities [ "azureOcr.extract" ] ) . toBeDefined ( ) ;
388+ expect ( result [ 0 ] . activities [ "formRecognizer.analyze" ] ) . toBeDefined ( ) ;
389+ } ) ;
390+
391+ it ( "produces separate results for different months, sorted ascending" , async ( ) => {
392+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [
393+ {
394+ activity_name : "azureOcr.extract" ,
395+ units_consumed : makeDecimal ( 10 ) ,
396+ created_at : new Date ( "2026-07-10T00:00:00Z" ) ,
397+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
398+ event_type : "activity_completed" ,
399+ workflow_version_id : "v1" ,
400+ } ,
401+ {
402+ activity_name : "azureOcr.extract" ,
403+ units_consumed : makeDecimal ( 5 ) ,
404+ created_at : new Date ( "2026-06-10T00:00:00Z" ) ,
405+ rate_version : { unit_cost_dollars : makeDecimal ( 0.001 ) } ,
406+ event_type : "activity_completed" ,
407+ workflow_version_id : "v1" ,
408+ } ,
409+ ] ) ;
410+
411+ const result = await service . getGroupActivityHistory ( "group-1" ) ;
412+ expect ( result ) . toHaveLength ( 2 ) ;
413+ expect ( result [ 0 ] . period_month ) . toBe ( 6 ) ;
414+ expect ( result [ 1 ] . period_month ) . toBe ( 7 ) ;
415+ } ) ;
416+
417+ it ( "passes date filters through to the Prisma query" , async ( ) => {
418+ mockPrisma . usageEvent . findMany . mockResolvedValue ( [ ] ) ;
419+ const start = new Date ( "2026-01-01" ) ;
420+ const end = new Date ( "2026-06-30" ) ;
421+
422+ await service . getGroupActivityHistory ( "group-1" , start , end ) ;
423+
424+ expect ( mockPrisma . usageEvent . findMany ) . toHaveBeenCalledWith (
425+ expect . objectContaining ( {
426+ where : expect . objectContaining ( {
427+ group_id : "group-1" ,
428+ created_at : { gte : start , lte : end } ,
429+ } ) ,
430+ } ) ,
431+ ) ;
432+ } ) ;
433+ } ) ;
225434
226435 // ---------------------------------------------------------------------------
227436 // getAllGroupsSummary
0 commit comments