diff --git a/Dockerfile b/Dockerfile index 785290c47..677ff2465 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,10 @@ RUN apk add --no-cache git python3 make g++ \ WORKDIR /app # Copy package.json and package-lock.json -COPY package*.json ./ +COPY package.json package-lock.json ./ + +# Fail early if lockfile or manifest is missing +RUN test -f package.json && test -f package-lock.json # Copy the source files COPY . . @@ -34,6 +37,7 @@ RUN npm cache clean --force && \ if [ $i -lt 3 ]; then \ sleep 15; \ else \ + cat /app/npm-debug.log || true; \ exit 1; \ fi; \ done diff --git a/src/backend/src/services/MeteringService/MeteringService.test.ts b/src/backend/src/services/MeteringService/MeteringService.test.ts index 6bd44d5c3..8251fef86 100644 --- a/src/backend/src/services/MeteringService/MeteringService.test.ts +++ b/src/backend/src/services/MeteringService/MeteringService.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createTestKernel } from '../../../tools/test.mjs'; import { MeteringServiceWrapper } from './MeteringServiceWrapper.mjs'; import { DBKVServiceWrapper } from '../repositories/DBKVStore/index.mjs'; @@ -13,6 +13,8 @@ import { TraceService } from '../TraceService'; import { Actor } from '../auth/Actor'; import { GetUserService } from '../GetUserService'; import { DetailProviderService } from '../DetailProviderService'; +import { GLOBAL_APP_KEY } from './consts.js'; + describe('MeteringService', async () => { config.load_config({ @@ -40,26 +42,177 @@ describe('MeteringService', async () => { await testKernel.services?.get('su').__on('boot.consolidation', []); const testSubject = testKernel.services!.get('meteringService') as MeteringServiceWrapper; + const eventService = testKernel.services!.get('event') as EventService; + const makeActor = (userUuid: string, appUid?: string, email?: string) => { + const actor = { + type: { + user: { + uuid: userUuid, + ...(email ? { email } : {}), + }, + ...(appUid ? { app: { uid: appUid } } : {}), + }, + } as unknown as Actor; + return actor; + }; it('should be instantiated', () => { expect(testSubject).toBeInstanceOf(MeteringServiceWrapper); }); it('should contain a copy of the public methods of meteringService too', () => { - // TODO DS: check all public MeteringService exist on the wrapper + const meteringMethods = Object.getOwnPropertyNames(MeteringService.prototype) + .filter((name) => name !== 'constructor'); + const wrapperMethods = testSubject as unknown as Record; + const missing = meteringMethods.filter((name) => typeof wrapperMethods[name] !== 'function'); + + expect(missing).toEqual([]); }); it('should have meteringService instantiated', async () => { expect(testSubject.meteringService).toBeInstanceOf(MeteringService); }); - it('should record usage for an actor', async () => { + it('should record usage for an actor properly', async () => { const res = await testSubject.meteringService.incrementUsage({ type: { user: { uuid: 'test-user-id' } } } as unknown as Actor, 'aws-polly:standard:character', 1); - console.log(res); + // TODO DS: validate the result properly expect(res).toBeDefined(); }); + it('utilRecordUsageObject delegates tracked usage to batchIncrementUsages', () => { + const actor = makeActor('util-user'); + const spy = vi.spyOn(testSubject.meteringService, 'batchIncrementUsages'); + + testSubject.meteringService.utilRecordUsageObject({ read: 2, write: 3 }, actor, 'kv', { write: 50 }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(actor, [ + { usageType: 'kv:read', usageAmount: 2, costOverride: undefined }, + { usageType: 'kv:write', usageAmount: 3, costOverride: 50 }, + ]); + spy.mockRestore(); + }); + + it('batchIncrementUsages aggregates totals per usage type', async () => { + const actor = makeActor('batch-user', 'batch-app'); + + const res = await testSubject.meteringService.batchIncrementUsages(actor, [ + { usageType: 'kv:write', usageAmount: 2 }, + { usageType: 'kv:read', usageAmount: 3 }, + ]); + + expect(res.total).toBe(439); // (125 * 2) + (63 * 3) + expect(res['kv:write']).toMatchObject({ units: 2, cost: 250, count: 1 }); + expect(res['kv:read']).toMatchObject({ units: 3, cost: 189, count: 1 }); + }); + + it('getActorCurrentMonthUsageDetails groups current app and others', async () => { + const userId = 'usage-detail-user'; + const actorAppOne = makeActor(userId, 'app-one'); + const actorAppTwo = makeActor(userId, 'app-two'); + + await testSubject.meteringService.incrementUsage(actorAppOne, 'kv:write', 1); + await testSubject.meteringService.incrementUsage(actorAppTwo, 'kv:read', 2); + + const details = await testSubject.meteringService.getActorCurrentMonthUsageDetails(actorAppOne); + + expect(details.usage.total).toBe(251); + expect(details.appTotals['app-one']).toMatchObject({ total: 125, count: 1 }); + expect(details.appTotals.others).toMatchObject({ total: 126, count: 1 }); + }); + + it('getActorCurrentMonthAppUsageDetails returns per-app usage', async () => { + const actor = makeActor('app-usage-user', 'app-usage-app'); + await testSubject.meteringService.incrementUsage(actor, 'kv:write', 1); + + const usage = await testSubject.meteringService.getActorCurrentMonthAppUsageDetails(actor); + + expect(usage.total).toBe(125); + expect(usage['kv:write']).toMatchObject({ cost: 125, units: 1, count: 1 }); + }); + + it('getActorCurrentMonthAppUsageDetails rejects when actor queries another app', async () => { + const actor = makeActor('app-usage-user-2', 'app-one'); + await expect(testSubject.meteringService.getActorCurrentMonthAppUsageDetails(actor, 'app-two')) + .rejects + .toThrow('Actor can only get usage details for their own app or global app'); + }); + + it('getAllowedUsage respects subscription overrides and consumed usage', async () => { + const actor = makeActor('limited-user'); + const customPolicy = { id: 'tiny', monthUsageAllowance: 10, monthlyStorageAllowance: 0 }; + const detPolicies = eventService.on('metering:registerAvailablePolicies', (_key, data) => { + data.availablePolicies.push(customPolicy); + }); + const detUserSub = eventService.on('metering:getUserSubscription', (_key, data) => { + data.userSubscriptionId = customPolicy.id; + }); + + try { + await testSubject.meteringService.incrementUsage(actor, 'kv:write', 1); + const allowed = await testSubject.meteringService.getAllowedUsage(actor); + + expect(allowed.monthUsageAllowance).toBe(10); + expect(allowed.remaining).toBe(0); + expect(allowed.addons).toEqual({}); + expect(await testSubject.meteringService.hasAnyUsage(actor)).toBe(false); + expect(await testSubject.meteringService.hasEnoughCreditsFor(actor, 'kv:read', 1)).toBe(false); + expect(await testSubject.meteringService.hasEnoughCredits(actor, 1)).toBe(false); + } finally { + detPolicies.detach(); + detUserSub.detach(); + } + }); + + it('updateAddonCredit stores addon credits retrievable via getActorAddons', async () => { + const userId = 'addon-user'; + await testSubject.meteringService.updateAddonCredit(userId, 500); + + const addons = await testSubject.meteringService.getActorAddons(makeActor(userId)); + + expect(addons).toMatchObject({ purchasedCredits: 500 }); + }); + + it('getGlobalUsage aggregates totals across shards', async () => { + const actor = makeActor('global-user', 'global-app'); + const before = await testSubject.meteringService.getGlobalUsage(); + await testSubject.meteringService.incrementUsage(actor, 'kv:write', 1); + const after = await testSubject.meteringService.getGlobalUsage(); + + const beforeRecord = before['kv:write'] || { cost: 0, units: 0, count: 0 }; + const afterRecord = after['kv:write'] || { cost: 0, units: 0, count: 0 }; + + expect(after.total - before.total).toBe(125); + expect(afterRecord.cost - beforeRecord.cost).toBe(125); + expect(afterRecord.units - beforeRecord.units).toBe(1); + expect(afterRecord.count - beforeRecord.count).toBe(1); + }); + + it('getActorAppUsage rejects when actor is scoped to another app', async () => { + const actor = makeActor('app-usage-user-3', 'app-one'); + await expect(testSubject.meteringService.getActorAppUsage(actor, 'app-two')) + .rejects + .toThrow('Actor can only get usage for their own app'); + }); + + it('getActorAppUsage returns zeroed usage when none exists', async () => { + const actor = makeActor('app-usage-user-4'); + const usage = await testSubject.meteringService.getActorAppUsage(actor, GLOBAL_APP_KEY); + + expect(usage).toMatchObject({ total: 0 }); + }); + + it('should record usage for an actor when cost is overwritten', async () => { + const actor = makeActor('overridden-cost-user'); + const res = await testSubject.meteringService.incrementUsage(actor, + 'aws-polly:standard:character', + 10, + 12); + + expect(res.total).toBe(12); + expect(res['aws-polly:standard:character']).toMatchObject({ cost: 12, units: 10, count: 1 }); + }); }); diff --git a/src/backend/src/services/MeteringService/MeteringService.ts b/src/backend/src/services/MeteringService/MeteringService.ts index 273515bfe..75d0b2afe 100644 --- a/src/backend/src/services/MeteringService/MeteringService.ts +++ b/src/backend/src/services/MeteringService/MeteringService.ts @@ -26,7 +26,7 @@ export class MeteringService { this.#eventService = eventService; } - utilRecordUsageObject>(trackedUsageObject: T, actor: Actor, modelPrefix: string, costsOverrides?: Record) { + utilRecordUsageObject>(trackedUsageObject: T, actor: Actor, modelPrefix: string, costsOverrides?: Partial>) { this.batchIncrementUsages(actor, Object.entries(trackedUsageObject).map(([usageKind, amount]) => ({ usageType: `${modelPrefix}:${usageKind}`, usageAmount: amount, @@ -105,7 +105,7 @@ export class MeteringService { const actorUsagesPromise = this.#kvStore.incr({ key: actorUsageKey, pathAndAmountMap, - }) as Promise; + }) as unknown as Promise; const puterConsumptionKey = this.#generateGloabalUsageKey(userId, appId, currentMonth); // global consumption across all users and apps this.#kvStore.incr({ @@ -257,7 +257,7 @@ export class MeteringService { const actorUsagesPromise = this.#kvStore.incr({ key: actorUsageKey, pathAndAmountMap: aggregatedPathAndAmountMap, - }) as Promise; + }) as unknown as Promise; const puterConsumptionKey = this.#generateGloabalUsageKey(userId, appId, currentMonth); this.#kvStore.incr({ @@ -517,7 +517,7 @@ export class MeteringService { } keys.push(`${keyPrefix}${currentMonth}`); // for initial unsharded data const usages = await this.#kvStore.get({ key: keys }) as UsageByType[]; - const aggregatedUsage: UsageByType = { total: 0 }; + const aggregatedUsage: UsageByType = { total: 0 } as UsageByType; usages.filter(Boolean).forEach(({ total, ...usage } = {} as UsageByType) => { aggregatedUsage.total += total || 0; diff --git a/src/backend/src/services/MeteringService/types.ts b/src/backend/src/services/MeteringService/types.ts index 5d666c8a0..58aa3f43c 100644 --- a/src/backend/src/services/MeteringService/types.ts +++ b/src/backend/src/services/MeteringService/types.ts @@ -20,7 +20,7 @@ export interface UsageRecord { units: number } -export type UsageByType = { [k: string]: number | UsageRecord } & { total: number }; +export type UsageByType = { total: number } & Partial, UsageRecord>>; export interface AppTotals { total: number, @@ -31,4 +31,4 @@ export interface MeteringServiceDeps { superUserService: SUService, alarmService: AlarmService eventService: EventService -} \ No newline at end of file +}