Files
OpenFrame/tests/api/lib-admin-stats.test.ts
T
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine
agents wrote suites in parallel against private databases, then a tenth read all
of it adversarially and five of its findings were fixed.

  unit + component  2076 -> 2079 (+888 over the round)
  api                647 -> 1015
  e2e                 18 -> 29

What was closed:

- lib/route-access.ts, the page-level authorization layer, went from zero tests
  to 48. Every API route was guarded and none of the pages were.
- The five media proxy routes now have a real 2xx beside every 403. The blocker
  was the positive control, solved by stubbing r2Client.send() and leaving
  lib/r2-media-proxy.ts itself real.
- Every remaining server-side lib module: invitations, email verification, the
  upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle,
  notifications and admin stats.
- Six video-page hooks, and the chunking arithmetic extracted out of
  lib/client/r2-video-upload.ts as a pure module.
- Five end-to-end flows: workspace members, bulk operations, the admin area,
  player interaction and failure recovery.

Three things about the harness itself turned out to be wrong:

- Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every
  route reaching finalizeR2VideoUpload silently took the "not a valid video"
  branch and no test noticed.
- The auth matrix asserted only "not 2xx", which two entries satisfied without
  their guard existing. It now requires 401 or 403, which makes both
  load-bearing, and all 60 routes pass the stricter form.
- Both admin API routes had no positive control anywhere: replacing their guard
  with an unconditional refusal left the entire suite green. Found by the
  adversarial review, now covered.

Process:

- bun run test:mutation runs StrykerJS over the authorization and validation
  modules. Diagnostic, not a gate, weekly in CI rather than on a push.
- playwright.config.ts gains an opt-in webkit project for the player spec.
- AGENTS.md now requires a batch of new tests to be reviewed by somebody who
  did not write them.

Only two production files change, both deliberate: lib/auth.ts loses a verbatim
copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the
extracted arithmetic. No behaviour change in either.
2026-07-26 13:25:11 +07:00

983 lines
35 KiB
TypeScript

// Exercises lib/admin-stats.ts, the aggregation behind the two admin
// dashboards, against real Postgres.
//
// Two things make the module worth a suite of its own rather than coverage
// through the pages that render it. First, `getCachedUserBunnyStorage` is not
// only a dashboard number: lib/storage-quota.ts adds its answer to a user's
// used bytes before granting an upload, so a grouping mistake here refuses or
// grants real uploads. Second, every figure is a byte count, and the
// download-egress path carries a BigInt column into a JavaScript number, which
// is exactly the seam this repo has already been bitten on.
//
// Only the boundaries are faked: `r2Client` (ListObjectsV2), global `fetch`
// (the Bunny API) and `getStripe()`. Every row the module aggregates over is a
// real row in the test database, because the aggregation is the thing under
// test.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// tests/setup/api.ts replaces `getCachedUserBunnyStorage` with a vi.fn() that
// answers `{}`, because it sits in the middle of reserveStorageQuota() and
// would otherwise reach the Bunny API from every upload test. This file is the
// one place that has to run the real implementation, so it drops that
// registration entirely. `vi.unmock` is hoisted like `vi.mock`, so it lands
// after the setup file's registration and wins. `mockRestore()` would not do
// the job: the stub is a bare vi.fn(), not a spy over the real export, so
// restoring it yields a function that returns undefined.
vi.unmock('@/lib/admin-stats');
import { DownloadEgressSource, VideoAssetKind, VideoAssetProvider } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import {
getCachedBunnyStorageStats,
getCachedStripeStats,
getCachedTotalStorage,
getCachedUserBunnyStorage,
getCachedUserDownloadEgress,
getCachedUserMediaStorage,
refreshR2StorageSnapshot,
} from '@/lib/admin-stats';
import {
createComment,
createProject,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
nextSeq,
seedProject,
seedVersion,
} from '../factories';
// ---------------------------------------------------------------------------
// R2 boundary
// ---------------------------------------------------------------------------
// vi.mock factories are hoisted above every const in the file, so the recorder
// has to be hoisted with them. The setup file stubs the named helpers in
// `@/lib/r2` but not `r2Client`, which is what listAllR2FileSizes() reaches
// for.
const r2 = vi.hoisted(() => ({
bucket: 'openframe-admin-stats-test-bucket',
/** Successive ListObjectsV2 responses, one consumed per send(). */
pages: [] as Array<{
Contents?: Array<{ Key?: string; Size?: number }>;
IsTruncated?: boolean;
NextContinuationToken?: string;
}>,
/** The continuation token on each send(), in call order. */
requestedTokens: [] as Array<string | undefined>,
/** The bucket on each send(), in call order. */
requestedBuckets: [] as string[],
/** Set to make the next send() reject, for the storage-unreachable path. */
failure: null as Error | null,
}));
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
return {
...actual,
R2_BUCKET_NAME: r2.bucket,
r2Client: {
send: async (command: { input?: { Bucket?: string; ContinuationToken?: string } }) => {
if (r2.failure) throw r2.failure;
r2.requestedBuckets.push(command.input?.Bucket ?? '');
r2.requestedTokens.push(command.input?.ContinuationToken);
return r2.pages[r2.requestedTokens.length - 1] ?? { Contents: [], IsTruncated: false };
},
},
};
});
// The snapshot lives on globalThis so it survives a Next.js server render, which
// also means it survives from one test to the next. Clear it, or a test that
// expects "no snapshot yet" passes or fails on file order.
const adminGlobals = globalThis as unknown as {
adminR2StorageSnapshot?: unknown;
adminR2StorageSnapshotPromise?: unknown;
};
beforeEach(() => {
delete adminGlobals.adminR2StorageSnapshot;
delete adminGlobals.adminR2StorageSnapshotPromise;
r2.pages = [];
r2.requestedTokens.length = 0;
r2.requestedBuckets.length = 0;
r2.failure = null;
// Every degrade path in this module logs through logError(). Keep the suite
// output readable without hiding a genuine failure.
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/**
* Takes an R2 snapshot holding exactly these keys and sizes.
*
* The recorders are cleared first because the fake client picks its response by
* call count, so a second snapshot in the same test would otherwise run off the
* end of the script and list an empty bucket.
*/
async function snapshotBucket(fileSizes: Record<string, number>): Promise<string> {
r2.requestedTokens.length = 0;
r2.requestedBuckets.length = 0;
r2.pages = [
{
Contents: Object.entries(fileSizes).map(([Key, Size]) => ({ Key, Size })),
IsTruncated: false,
},
];
return refreshR2StorageSnapshot();
}
// ---------------------------------------------------------------------------
// Bunny boundary
// ---------------------------------------------------------------------------
interface BunnyPage {
status?: number;
body?: unknown;
}
interface BunnyCalls {
urls: string[];
accessKeys: Array<string | undefined>;
}
/** Answers the Bunny video-list endpoint with these pages, in order. */
function stubBunnyPages(pages: BunnyPage[]): BunnyCalls {
const calls: BunnyCalls = { urls: [], accessKeys: [] };
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown, init?: { headers?: Record<string, string> }) => {
calls.urls.push(String(input));
calls.accessKeys.push(init?.headers?.AccessKey);
// Past the end of the script, answer an empty page so a runaway loop
// terminates instead of hanging the suite.
const page = pages[calls.urls.length - 1] ?? { body: { items: [] } };
const status = page.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
json: async () => page.body,
};
})
);
return calls;
}
/** Credentials plus a one-page library holding exactly these videos. */
function stubBunnyLibrary(sizes: Record<string, number>): BunnyCalls {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
const items = Object.entries(sizes).map(([guid, storageSize]) => ({ guid, storageSize }));
return stubBunnyPages([{ body: { items, totalItems: items.length } }]);
}
function bunnyCredentials(): void {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
}
// ---------------------------------------------------------------------------
// Download egress
// ---------------------------------------------------------------------------
// DownloadEgressEvent carries no foreign keys (see prisma/schema.prisma), so
// the ids other than billedUserId can be synthetic. There is no factory for it
// and tests/factories is shared, so the builder lives here.
async function recordDownload(billedUserId: string, estimatedBytes: bigint): Promise<void> {
const seq = nextSeq();
await db.downloadEgressEvent.create({
data: {
versionId: `egress-version-${seq}`,
videoId: `egress-video-${seq}`,
projectId: `egress-project-${seq}`,
workspaceId: `egress-workspace-${seq}`,
billedUserId,
source: DownloadEgressSource.ORIGINAL,
estimatedBytes,
},
});
}
// ---------------------------------------------------------------------------
// Stripe boundary
// ---------------------------------------------------------------------------
/** Installs a Stripe double whose price lookup answers this price. */
function stubStripePrice(price: { unit_amount?: number | null; currency?: string }): {
retrievedPriceIds: string[];
} {
const retrievedPriceIds: string[] = [];
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
prices: {
retrieve: vi.fn(async (priceId: string) => {
retrievedPriceIds.push(priceId);
return price;
}),
},
});
return { retrievedPriceIds };
}
describe('refreshR2StorageSnapshot', () => {
it('walks every page of the bucket listing and totals the object sizes', async () => {
r2.pages = [
{
Contents: [
{ Key: 'voice/one.webm', Size: 100 },
{ Key: 'images/two.png', Size: 250 },
],
IsTruncated: true,
NextContinuationToken: 'page-two',
},
{
Contents: [{ Key: 'videos/three.mp4', Size: 1000 }],
IsTruncated: false,
},
];
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(1350);
// The second request must carry the token the first one handed back, or the
// listing silently stops at 1000 objects and every total is wrong.
expect(r2.requestedTokens).toEqual([undefined, 'page-two']);
expect(r2.requestedBuckets).toEqual([r2.bucket, r2.bucket]);
});
it('counts an object with no reported size as zero and skips one with no key', async () => {
r2.pages = [
{
Contents: [
{ Key: 'voice/sizeless.webm' },
{ Key: 'images/known.png', Size: 40 },
{ Size: 999 },
],
IsTruncated: false,
},
];
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(40);
});
it('reports an empty bucket as zero bytes rather than as unavailable', async () => {
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(0);
});
it('answers the moment of the refresh as an ISO timestamp', async () => {
const before = Date.now();
const refreshedAt = await refreshR2StorageSnapshot();
expect(refreshedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(Date.parse(refreshedAt)).toBeGreaterThanOrEqual(before);
expect(Date.parse(refreshedAt)).toBeLessThanOrEqual(Date.now());
});
it('propagates a storage failure to the caller instead of storing a partial snapshot', async () => {
await snapshotBucket({ 'images/kept.png': 77 });
r2.failure = new Error('storage is unreachable');
await expect(refreshR2StorageSnapshot()).rejects.toThrow('storage is unreachable');
// The previous snapshot survives, so the dashboard keeps showing the last
// known figure rather than dropping to the -1 sentinel.
r2.failure = null;
expect(await getCachedTotalStorage()).toBe(77);
});
});
describe('getCachedTotalStorage', () => {
it('answers -1 when no snapshot has been taken yet', async () => {
expect(await getCachedTotalStorage()).toBe(-1);
});
it('never touches storage on its own, so the sentinel is not a listing failure', async () => {
await getCachedTotalStorage();
expect(r2.requestedTokens).toEqual([]);
});
it('answers the byte total of the most recent snapshot', async () => {
await snapshotBucket({ 'images/a.png': 5 });
expect(await getCachedTotalStorage()).toBe(5);
await snapshotBucket({ 'images/a.png': 5, 'voice/b.webm': 15 });
expect(await getCachedTotalStorage()).toBe(20);
});
});
describe('getCachedBunnyStorageStats', () => {
it('totals the library and indexes each video by its guid', async () => {
const calls = stubBunnyLibrary({ 'bunny-one': 500, 'bunny-two': 250 });
const stats = await getCachedBunnyStorageStats();
expect(stats).toEqual({ totalBytes: 750, byVideoId: { 'bunny-one': 500, 'bunny-two': 250 } });
expect(calls.urls).toHaveLength(1);
expect(calls.urls[0]).toBe(
'https://video.bunnycdn.com/library/4242/videos?page=1&itemsPerPage=100'
);
expect(calls.accessKeys).toEqual(['bunny-key-for-admin-stats']);
});
it('stops requesting pages once the reported item count is covered', async () => {
bunnyCredentials();
const calls = stubBunnyPages([
{ body: { items: [{ guid: 'bunny-one', storageSize: 10 }], totalItems: 1 } },
]);
const stats = await getCachedBunnyStorageStats();
expect(stats.totalBytes).toBe(10);
expect(calls.urls).toHaveLength(1);
});
it('keeps paging when the API reports no total and stops at the first empty page', async () => {
bunnyCredentials();
const calls = stubBunnyPages([
{ body: { items: [{ guid: 'bunny-one', storageSize: 10 }] } },
{ body: { items: [{ guid: 'bunny-two', storageSize: 32 }] } },
{ body: { items: [] } },
]);
const stats = await getCachedBunnyStorageStats();
expect(stats).toEqual({ totalBytes: 42, byVideoId: { 'bunny-one': 10, 'bunny-two': 32 } });
expect(calls.urls).toHaveLength(3);
expect(calls.urls[1]).toContain('page=2');
expect(calls.urls[2]).toContain('page=3');
});
it('reads the capitalised item list Bunny sometimes returns', async () => {
bunnyCredentials();
stubBunnyPages([{ body: { Items: [{ guid: 'bunny-one', storageSize: 64 }], TotalItems: 1 } }]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 64,
byVideoId: { 'bunny-one': 64 },
});
});
it('accepts any of the three size field names and treats an absent one as zero', async () => {
bunnyCredentials();
stubBunnyPages([
{
body: {
items: [
{ guid: 'by-storage-size', storageSize: 5 },
{ guid: 'by-storage', storage: 7 },
{ guid: 'by-size', size: 11 },
{ guid: 'no-size-at-all' },
{ guid: 'negative-size', size: -400 },
],
totalItems: 5,
},
},
]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 23,
byVideoId: {
'by-storage-size': 5,
'by-storage': 7,
'by-size': 11,
'no-size-at-all': 0,
'negative-size': 0,
},
});
});
it('skips an item with no guid rather than indexing it under an empty key', async () => {
bunnyCredentials();
stubBunnyPages([
{
body: {
items: [{ storageSize: 900 }, { guid: 'bunny-one', storageSize: 3 }],
totalItems: 2,
},
},
]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 3,
byVideoId: { 'bunny-one': 3 },
});
});
// -1 rather than 0 is the whole point: 0 would tell the dashboard that Bunny
// holds nothing, and would tell getCachedUserBunnyStorage to charge every
// user zero bytes.
it('degrades to -1 instead of throwing when the Bunny API answers an error', async () => {
bunnyCredentials();
stubBunnyPages([{ status: 500, body: {} }]);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
});
it('degrades to -1 instead of throwing when fetch itself fails', async () => {
bunnyCredentials();
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('network timeout');
})
);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
});
// The flag defaults to on, so a self-hosted deployment that never configured
// Bunny lands here: credentials missing while the feature is nominally
// enabled.
it('degrades to -1 when the Bunny credentials are not configured', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', undefined);
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', undefined);
const calls = stubBunnyPages([]);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
expect(calls.urls).toEqual([]);
});
it('reports a genuine zero without calling Bunny when the feature flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', 'false');
const calls = stubBunnyLibrary({ 'bunny-one': 500 });
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: 0, byVideoId: {} });
expect(calls.urls).toEqual([]);
});
it('falls back to the public library id when the server-side one is unset', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', '7171');
const calls = stubBunnyPages([{ body: { items: [], totalItems: 0 } }]);
await getCachedBunnyStorageStats();
expect(calls.urls[0]).toContain('/library/7171/videos');
});
});
// This block is the reason the file unmocks `@/lib/admin-stats`: everywhere
// else in the api project these calls answer `{}`.
describe('getCachedUserBunnyStorage', () => {
it('attributes each Bunny video to the owner of the project it lives in', async () => {
const first = await seedProject();
const second = await seedProject();
const firstVideo = await createVideo({ projectId: first.project.id });
const secondVideo = await createVideo({ projectId: second.project.id });
await createVersion({
videoParentId: firstVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
await createVersion({
videoParentId: secondVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-second',
});
stubBunnyLibrary({ 'bunny-first': 500, 'bunny-second': 900 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [first.owner.id]: 500, [second.owner.id]: 900 });
});
it('adds up several Bunny videos owned by the same user', async () => {
const scenario = await seedProject();
const firstVideo = await createVideo({ projectId: scenario.project.id });
const secondVideo = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: firstVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
await createVersion({
videoParentId: secondVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-second',
});
stubBunnyLibrary({ 'bunny-first': 500, 'bunny-second': 900 });
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 1400 });
});
it('bills a Bunny asset to the user it records as the billed user', async () => {
const scenario = await seedProject();
const uploader = await createUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-asset',
});
stubBunnyLibrary({ 'bunny-asset': 1234 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [uploader.id]: 1234 });
expect(perUser[scenario.owner.id]).toBeUndefined();
});
// Two versions of the same Bunny video (a relabelled upload, say) are one
// object in the library, so charging the owner twice would inflate the number
// that lib/storage-quota.ts checks an upload against.
it('counts a Bunny video once per user however many rows point at it', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
versionNumber: 1,
providerId: 'bunny',
providerVideoId: 'bunny-shared',
});
await createVersion({
videoParentId: video.id,
versionNumber: 2,
providerId: 'bunny',
providerVideoId: 'bunny-shared',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-shared',
});
stubBunnyLibrary({ 'bunny-shared': 700 });
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 700 });
});
it('ignores versions and assets that are not on Bunny', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'r2',
providerVideoId: 'bunny-first',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: VideoAssetProvider.R2_IMAGE,
});
stubBunnyLibrary({ 'bunny-first': 500 });
expect(await getCachedUserBunnyStorage()).toEqual({});
});
// An orphan: the row still names a Bunny video that the library no longer
// holds. It has to resolve to zero bytes rather than to undefined, or the
// arithmetic downstream turns into NaN.
it('charges zero for a Bunny video the library no longer knows about', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-deleted-upstream',
});
stubBunnyLibrary({ 'bunny-still-there': 4096 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [scenario.owner.id]: 0 });
expect(Number.isNaN(perUser[scenario.owner.id])).toBe(false);
});
// The -1 sentinel means "Bunny did not answer", and the module must not turn
// that into "this user stores nothing", because lib/storage-quota.ts would
// then hand out headroom the user does not have. An empty map at least leaves
// the R2 figures intact.
it('answers an empty map when the Bunny library could not be read', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
bunnyCredentials();
stubBunnyPages([{ status: 503, body: {} }]);
expect(await getCachedUserBunnyStorage()).toEqual({});
});
it('is empty on a database with no videos at all', async () => {
stubBunnyLibrary({ 'bunny-orphan': 999 });
expect(await getCachedUserBunnyStorage()).toEqual({});
});
});
describe('getCachedUserMediaStorage', () => {
it('splits a user comment media into voice and image and totals both', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
voiceUrl: '/api/upload/audio/note.webm',
imageUrl: '/api/upload/image/shot.png',
});
await snapshotBucket({ 'voice/note.webm': 300, 'images/shot.png': 700 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 1000, voice: 300, image: 700 },
});
});
// Comment media is billed through the workspace owner, matching the join in
// lib/storage-quota.ts, not through the owner of the project the comment
// happens to sit in.
it('bills comment media to the workspace owner rather than the project owner', async () => {
const workspaceOwner = await createUser();
const projectOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
const video = await createVideo({ projectId: project.id });
const version = await createVersion({ videoParentId: video.id });
await createComment({ versionId: version.id, voiceUrl: '/api/upload/audio/note.webm' });
await snapshotBucket({ 'voice/note.webm': 120 });
const perUser = await getCachedUserMediaStorage();
expect(perUser).toEqual({ [workspaceOwner.id]: { total: 120, voice: 120, image: 0 } });
expect(perUser[projectOwner.id]).toBeUndefined();
});
it('adds R2 image and audio assets to the user they are billed to', async () => {
const scenario = await seedProject();
const uploader = await createUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
provider: VideoAssetProvider.R2_IMAGE,
sourceUrl: '/api/upload/image/asset.png',
});
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
kind: VideoAssetKind.AUDIO,
provider: VideoAssetProvider.R2_AUDIO,
sourceUrl: '/api/upload/audio/asset.webm',
});
await snapshotBucket({ 'images/asset.png': 11, 'voice/asset.webm': 22 });
expect(await getCachedUserMediaStorage()).toEqual({
[uploader.id]: { total: 33, voice: 22, image: 11 },
});
});
it('leaves video and Bunny assets out, since they are not comment media', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.R2_VIDEO,
sourceUrl: '/api/upload/video/movie.mp4',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-one',
sourceUrl: '/api/upload/video/other.mp4',
});
await snapshotBucket({ 'images/movie.mp4': 5000, 'voice/other.mp4': 5000 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
// The same object reached through both a comment row and an asset row is one
// object in the bucket, so it must be charged once.
it('counts an object once when both a comment and an asset point at it', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
imageUrl: '/api/upload/image/same.png',
});
await createVideoAsset({
videoId: scenario.video.id,
billedUserId: scenario.owner.id,
provider: VideoAssetProvider.R2_IMAGE,
sourceUrl: '/api/upload/image/same.png',
});
await snapshotBucket({ 'images/same.png': 640 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 640, voice: 0, image: 640 },
});
});
it('keeps one user media out of another user total', async () => {
const first = await seedVersion();
const second = await seedVersion();
await createComment({
versionId: first.version.id,
imageUrl: '/api/upload/image/first.png',
});
await createComment({
versionId: second.version.id,
imageUrl: '/api/upload/image/second.png',
});
await snapshotBucket({ 'images/first.png': 100, 'images/second.png': 900 });
expect(await getCachedUserMediaStorage()).toEqual({
[first.owner.id]: { total: 100, voice: 0, image: 100 },
[second.owner.id]: { total: 900, voice: 0, image: 900 },
});
});
// An orphaned row: the comment still names a file that is no longer in the
// bucket. The user must still appear, at zero, rather than vanish or count
// NaN bytes.
it('charges zero for a comment whose file is no longer in the bucket', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
voiceUrl: '/api/upload/audio/deleted.webm',
});
await snapshotBucket({ 'images/unrelated.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 0, voice: 0, image: 0 },
});
});
it('ignores comments that carry no media at all', async () => {
const scenario = await seedVersion();
await createComment({ versionId: scenario.version.id, content: 'just text' });
await snapshotBucket({ 'images/unrelated.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
it('is empty on an empty database', async () => {
await snapshotBucket({ 'images/orphan.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
// Without a snapshot the module cannot size anything, and it answers an empty
// map rather than a map full of zeros. The difference matters: zeros would
// read on the dashboard as "this user stores nothing".
it('answers an empty map when no R2 snapshot has been taken', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
imageUrl: '/api/upload/image/shot.png',
});
expect(await getCachedUserMediaStorage()).toEqual({});
});
});
describe('getCachedUserDownloadEgress', () => {
it('sums the estimated bytes of every download billed to a user', async () => {
const first = await createUser();
const second = await createUser();
await recordDownload(first.id, BigInt(1000));
await recordDownload(first.id, BigInt(2500));
await recordDownload(second.id, BigInt(7));
expect(await getCachedUserDownloadEgress()).toEqual({
[first.id]: 3500,
[second.id]: 7,
});
});
it('is empty when nothing has been downloaded', async () => {
await createUser();
expect(await getCachedUserDownloadEgress()).toEqual({});
});
it('reports zero for a user whose downloads all measured zero bytes', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(0));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 0 });
});
// estimatedBytes is a BigInt column and the dashboard wants a number. A
// 9 PB total is not realistic, but the conversion is the same one every row
// goes through, and the answer must be bytes rather than any other unit.
it('returns the sum in bytes, not in any larger unit', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(5) * BigInt(1024) * BigInt(1024));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 5_242_880 });
});
it('carries a sum that fits in a double across exactly', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(Number.MAX_SAFE_INTEGER));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 9_007_199_254_740_991 });
});
// Past 2^53 a Number() cast silently rounds. The module clamps instead, so an
// absurd total reads as "at the ceiling" rather than as a quietly wrong
// figure.
it('clamps a sum beyond the safe integer range instead of rounding it', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(Number.MAX_SAFE_INTEGER));
await recordDownload(user.id, BigInt(1000));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: Number.MAX_SAFE_INTEGER });
});
});
describe('getCachedStripeStats', () => {
beforeEach(() => {
// getStripe() is mocked in tests/setup/api.ts and its implementation is
// module state, so it survives afterEach. Reset it so no test inherits
// another test's price.
vi.mocked(getStripe as unknown as () => unknown).mockReset();
vi.stubEnv('STRIPE_PRICE_ID', 'price_admin_stats_test');
});
it('counts users by subscription status and prices the active ones', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'TRIALING' });
await createUser({ subscriptionStatus: 'PAST_DUE' });
await createUser({ subscriptionStatus: 'CANCELED' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'eur' });
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 2,
trialingUsers: 1,
pastDueUsers: 1,
canceledUsers: 1,
freeUsers: 3,
mrrCents: 3800,
currency: 'eur',
});
expect(stripe.retrievedPriceIds).toEqual(['price_admin_stats_test']);
});
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED are real values of the enum that
// the report has no bucket for. They must not be silently folded into one of
// the five that are reported.
it('leaves statuses it does not report out of every bucket', async () => {
await createUser({ subscriptionStatus: 'UNPAID' });
await createUser({ subscriptionStatus: 'INCOMPLETE' });
await createUser({ subscriptionStatus: 'INCOMPLETE_EXPIRED' });
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
const stats = await getCachedStripeStats();
expect(stats).toEqual({
activeSubscribers: 0,
trialingUsers: 0,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
it('reports zeros and no revenue on an empty database', async () => {
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 0,
trialingUsers: 0,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
// The user counts come from the database and are still true when Stripe is
// down, so the module keeps them and only the revenue figure degrades.
it('keeps the user counts and degrades the revenue to zero usd when Stripe fails', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'TRIALING' });
vi.mocked(getStripe as unknown as () => unknown).mockImplementation(() => {
throw new Error('Stripe is unreachable');
});
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 1,
trialingUsers: 1,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
it('degrades the revenue when the price lookup itself rejects', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
prices: {
retrieve: vi.fn(async () => {
throw new Error('price not found');
}),
},
});
const stats = await getCachedStripeStats();
expect(stats?.activeSubscribers).toBe(1);
expect(stats?.mrrCents).toBe(0);
expect(stats?.currency).toBe('usd');
});
it('treats a price with no unit amount as free rather than as NaN', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
stubStripePrice({ unit_amount: null, currency: 'gbp' });
const stats = await getCachedStripeStats();
expect(stats?.mrrCents).toBe(0);
expect(stats?.currency).toBe('gbp');
});
it('answers null without querying Stripe when the billing flag is off', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'usd' });
expect(await getCachedStripeStats()).toBeNull();
expect(stripe.retrievedPriceIds).toEqual([]);
});
it('answers null when the flag is on but Stripe is not configured', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.stubEnv('STRIPE_SECRET_KEY', undefined);
expect(await getCachedStripeStats()).toBeNull();
});
});