mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-23 00:28:37 -05:00
feat: meteringService test suite (#2052)
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export class MeteringService {
|
||||
this.#eventService = eventService;
|
||||
}
|
||||
|
||||
utilRecordUsageObject<T extends Record<string, number>>(trackedUsageObject: T, actor: Actor, modelPrefix: string, costsOverrides?: Record<keyof T, number>) {
|
||||
utilRecordUsageObject<T extends Record<string, number>>(trackedUsageObject: T, actor: Actor, modelPrefix: string, costsOverrides?: Partial<Record<keyof T, number>>) {
|
||||
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<UsageByType>;
|
||||
}) as unknown as Promise<UsageByType>;
|
||||
|
||||
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<UsageByType>;
|
||||
}) as unknown as Promise<UsageByType>;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface UsageRecord {
|
||||
units: number
|
||||
}
|
||||
|
||||
export type UsageByType = { [k: string]: number | UsageRecord } & { total: number };
|
||||
export type UsageByType = { total: number } & Partial<Record<Exclude<string, 'total'>, UsageRecord>>;
|
||||
|
||||
export interface AppTotals {
|
||||
total: number,
|
||||
@@ -31,4 +31,4 @@ export interface MeteringServiceDeps {
|
||||
superUserService: SUService,
|
||||
alarmService: AlarmService
|
||||
eventService: EventService
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user