diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 6249bf8..9a568e1 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -77,6 +77,8 @@ interface StorageInfo { usedBytes: string; limitBytes: string; percentage: number; + /** False on the free trial, where the way out is subscribing rather than deleting. */ + isPaid: boolean; } function formatBytes(bytesStr: string): string { @@ -501,7 +503,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo Storage - Combined usage across video files and media attachments (200 GB limit) + Combined usage across video files and media attachments + {storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''} @@ -541,11 +544,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo : '' } /> - {storageInfo.percentage >= 90 && ( -

- Storage is almost full. Delete unused files or contact support. -

- )} + {storageInfo.percentage >= 90 && + (storageInfo.isPaid ? ( +

+ Storage is almost full. Delete unused files or contact support. +

+ ) : ( +

+ Your free trial storage is almost full. Subscribe above for more room, or + delete unused files. +

+ ))} )}
diff --git a/app/api/settings/storage/route.ts b/app/api/settings/storage/route.ts index c057a27..2edbed6 100644 --- a/app/api/settings/storage/route.ts +++ b/app/api/settings/storage/route.ts @@ -1,6 +1,6 @@ import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; -import { getUserStorageInfo } from '@/lib/storage-quota'; +import { getStorageContextForUser, getUserStorageInfo } from '@/lib/storage-quota'; import { hasBillingAccess } from '@/lib/billing'; import { db } from '@/lib/db'; @@ -27,12 +27,19 @@ export async function GET() { return apiErrors.forbidden(); } - const info = await getUserStorageInfo(session.user.id); + const [info, storage] = await Promise.all([ + getUserStorageInfo(session.user.id), + getStorageContextForUser(session.user.id), + ]); const response = successResponse({ usedBytes: info.usedBytes.toString(), limitBytes: info.limitBytes.toString(), percentage: info.percentage, + // Which ceiling this is, so the card can name it and say what to do about it. + // A trial has 3 GB because it has not subscribed; deleting files is the wrong + // advice there, and "200 GB limit" was the wrong caption. + isPaid: storage.isPaid, }); // Cache for 60s — stale data is acceptable for a usage meter diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts index 4573b54..956c5fb 100644 --- a/app/api/videos/[videoId]/assets/route.ts +++ b/app/api/videos/[videoId]/assets/route.ts @@ -32,11 +32,13 @@ import { enforceStorageQuota, reserveStorageQuota, releaseStorageReservation, - getStorageLimitForUser, + getStorageContextForUser, + storageExceededResponse, UPLOAD_RESERVATION_PURPOSES, + type StorageContext, type UploadReservationPurpose, } from '@/lib/storage-quota'; -import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; +import { getUserBunnyStorageBytes } from '@/lib/admin-stats'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; // Sentinel thrown inside a Prisma transaction when a fake reservationId is @@ -307,6 +309,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // was still in flight. let reservationPurpose: UploadReservationPurpose | null = null; let reservationBilledUserId: string | null = null; + // Carried out of the try so the quota refusal in the catch can be worded for + // the account it is refusing, rather than telling a trial to delete files. + let storageForRefusal: StorageContext | null = null; let finalizedR2AssetSession: { sessionId: string; reservationId: string | null; @@ -582,14 +587,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // quota check below, Bunny included. Leaving Bunny out read its own storage as // zero, and on an account whose storage is all Bunny that made the fallback a // check that could not fail. - const preFetchedBunnyData = - provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage(); + const preFetchedBunnyBytes = + provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId); // The ceiling this account is actually held to, read for the fallback below. // It used to compare against the plan limit, which is 200 GiB whoever is // asking: a caller who quoted a reservation id that no longer existed was // measured against the paid ceiling even on a trial worth 3 GiB. - const storageLimitBytes = await getStorageLimitForUser(billedUserId); + const storage = await getStorageContextForUser(billedUserId); + storageForRefusal = storage; // Create the VideoAsset and atomically consume the upload reservation (if any) // so the spot is never double-counted. @@ -631,12 +637,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { WHERE "billedUserId" = ${billedUserId} AND "expiresAt" > NOW() `; - const bunnyData = preFetchedBunnyData ?? {}; const totalUsed = (r2Row?.total ?? BigInt(0)) + (resRow?.total ?? BigInt(0)) + - BigInt(bunnyData[billedUserId] ?? 0); - if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) { + BigInt(preFetchedBunnyBytes ?? 0); + if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) { throw new QuotaExceededInTxError(); } } @@ -712,7 +717,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return withCacheControl(response, 'private, no-store'); } catch (error) { if (error instanceof QuotaExceededInTxError) { - return apiErrors.storageExceeded() as NextResponse; + return storageForRefusal + ? storageExceededResponse(storageForRefusal) + : (apiErrors.storageExceeded() as NextResponse); } await releaseStorageReservation( reservationId, diff --git a/lib/admin-stats.ts b/lib/admin-stats.ts index 39955f0..8c57937 100644 --- a/lib/admin-stats.ts +++ b/lib/admin-stats.ts @@ -221,12 +221,97 @@ export const getCachedBunnyStorageStats = unstable_cache( { revalidate: STORAGE_CACHE_SECONDS } ); +/** + * What this video costs us, as the larger of the two numbers we have. + * + * Bunny reports nothing for a video until it starts encoding, and what it reports + * while encoding is partial: `storageSize` counts what has been written so far and + * climbs as each rendition lands. A six minute cut uploaded at 2.5 GB read as + * 475 MB midway through and settled above 3 GB once it finished, because Bunny + * keeps the original alongside every rendition it makes. + * + * Both halves of the rule follow from that. Taking Bunny's figure whenever it is + * non-zero would hand back most of the quota in the middle of an encode, which is + * the hole the declared size exists to close. Taking the declared size forever + * would ignore the renditions, which are the actual bill and end up larger than + * the source. The larger of the two is right at every point: the declared size + * covers the encode, and Bunny's own number takes over the moment it passes it. + */ +function chargeableSize(reported: number, declared: bigint | null): number { + const declaredBytes = declared === null ? 0 : Number(declared); + return reported > declaredBytes ? reported : declaredBytes; +} + +/** + * Bunny's reported sizes, or an empty map when the call to Bunny failed. + * + * A failed stats call is not a reason to bill an account for nothing. Bunny's own + * figure is unavailable; the sizes declared at upload are sitting in our database + * either way, and reading the whole account as empty is how a full account gets + * waved through. Used to be an early return that skipped the rows entirely. + */ +function reportedSizes(stats: BunnyStorageStats): Record { + return stats.totalBytes < 0 ? {} : stats.byVideoId; +} + +/** + * What one account's Bunny videos cost, read fresh. + * + * This deliberately does not come from the cached per-user map. The declared size + * lands on the row at the moment an upload finalizes, and a map computed up to two + * minutes earlier does not have that row in it. For those two minutes the + * reservation is already gone and the row is not yet visible, so an upload that + * just succeeded reads as zero: the uploader watches their usage fall back to + * nothing, and the next upload is measured against a total that ignores the one + * before it. + * + * The call to Bunny stays cached. It is the slow half and its answer is the same + * for everybody. Only the join against our own rows has to be current. + */ +export async function getUserBunnyStorageBytes(userId: string): Promise { + try { + const [bunnyStats, bunnyVersions, bunnyAssets] = await Promise.all([ + getCachedBunnyStorageStats(), + db.videoVersion.findMany({ + where: { + providerId: 'bunny', + // The workspace owner, not the project owner: this feeds + // getUserTotalStorageBytes, which bills every other provider the same way. + video: { project: { workspace: { ownerId: userId } } }, + }, + select: { videoId: true, sizeBytes: true }, + }), + db.videoAsset.findMany({ + where: { provider: 'BUNNY', providerVideoId: { not: null }, billedUserId: userId }, + select: { providerVideoId: true, sizeBytes: true }, + }), + ]); + + const reported = reportedSizes(bunnyStats); + const seenVideoIds = new Set(); + let total = 0; + + for (const row of [ + ...bunnyVersions.map((v) => ({ videoId: v.videoId, sizeBytes: v.sizeBytes })), + ...bunnyAssets.map((a) => ({ videoId: a.providerVideoId!, sizeBytes: a.sizeBytes })), + ]) { + if (!row.videoId || seenVideoIds.has(row.videoId)) continue; + seenVideoIds.add(row.videoId); + total += chargeableSize(reported[row.videoId] || 0, row.sizeBytes); + } + + return total; + } catch (err) { + logError('Failed to calculate Bunny storage for user:', err); + return 0; + } +} + export const getCachedUserBunnyStorage = unstable_cache( async () => { const perUserStorage: Record = {}; try { const bunnyStats = await getCachedBunnyStorageStats(); - if (bunnyStats.totalBytes < 0) return perUserStorage; const [bunnyVersions, bunnyAssets] = await Promise.all([ db.videoVersion.findMany({ @@ -262,23 +347,7 @@ export const getCachedUserBunnyStorage = unstable_cache( }), ]); - /** - * What this video costs us, as the larger of the two numbers we have. - * - * Bunny reports nothing for a video until it has finished encoding it, - * which on a half-hour source is most of an hour, and reading that zero - * literally meant an upload was free for as long as it was being - * processed: it did not show on the uploader's storage page and it did not - * count against the next upload's quota check. The size declared when the - * upload was admitted stands in until Bunny has a figure of its own, and - * Bunny's wins once it arrives, because the renditions it makes are the - * real bill and they are larger than the source. - */ - const chargeableSize = (reported: number, declared: bigint | null): number => { - const declaredBytes = declared === null ? 0 : Number(declared); - return reported > declaredBytes ? reported : declaredBytes; - }; - + const reported = reportedSizes(bunnyStats); const seenVideoIds = new Set(); for (const version of bunnyVersions) { const ownerId = version.video.project.workspace.ownerId; @@ -286,7 +355,7 @@ export const getCachedUserBunnyStorage = unstable_cache( if (seenVideoIds.has(dedupeKey)) continue; seenVideoIds.add(dedupeKey); - const size = chargeableSize(bunnyStats.byVideoId[version.videoId] || 0, version.sizeBytes); + const size = chargeableSize(reported[version.videoId] || 0, version.sizeBytes); perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size; } @@ -297,10 +366,7 @@ export const getCachedUserBunnyStorage = unstable_cache( if (seenVideoIds.has(dedupeKey)) continue; seenVideoIds.add(dedupeKey); - const size = chargeableSize( - bunnyStats.byVideoId[asset.providerVideoId] || 0, - asset.sizeBytes - ); + const size = chargeableSize(reported[asset.providerVideoId] || 0, asset.sizeBytes); perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size; } } catch (err) { diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts index c01d82b..fcdf088 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -2,7 +2,7 @@ import type { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { apiErrors } from '@/lib/api-response'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; -import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; +import { getUserBunnyStorageBytes } from '@/lib/admin-stats'; import { isPaidTier } from '@/lib/billing'; import { getStorageLimitBytes } from '@/lib/trial-limits'; @@ -18,12 +18,59 @@ export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024 * forget to pass it. */ export async function getStorageLimitForUser(userId: string): Promise { + return (await getStorageContextForUser(userId)).limitBytes; +} + +export interface StorageContext { + /** The ceiling this account is held to. */ + limitBytes: bigint; + /** Whether that ceiling is the plan's or the trial's. */ + isPaid: boolean; +} + +/** + * The ceiling and the reason for it, read together. + * + * The two travel as a pair because a refusal has to say which one it is: an + * unpaid account is out of room because it has not subscribed, and telling it to + * delete files is advice that does not apply. + */ +export async function getStorageContextForUser(userId: string): Promise { const user = await db.user.findUnique({ where: { id: userId }, select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, }); - return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES); + const isPaid = user ? isPaidTier(user) : false; + return { limitBytes: getStorageLimitBytes(isPaid, PLAN_STORAGE_LIMIT_BYTES), isPaid }; +} + +/** + * Whole gigabytes where the number is whole, which every ceiling we ship is. + * A host that sets an odd one gets a decimal rather than a rounded lie. + */ +function formatStorageLimit(bytes: bigint): string { + const gigabytes = Number(bytes) / 1024 ** 3; + return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`; +} + +/** + * The refusal, in the words that fit the account it is being given to. + * + * A paying account that has filled 200 GB has to delete something. An unpaid one + * has three gigabytes because it has not subscribed, so the way out is the + * subscription, and the response says so under its own error code rather than + * leaving the client to guess from the number. + */ +export function storageExceededResponse(context: StorageContext): NextResponse { + if (context.isPaid) { + return apiErrors.storageExceeded() as NextResponse; + } + + return apiErrors.trialStorageExceeded( + `Your free trial includes ${formatStorageLimit(context.limitBytes)} of storage. ` + + `Upgrade to get ${formatStorageLimit(PLAN_STORAGE_LIMIT_BYTES)}.` + ) as NextResponse; } // TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads @@ -66,7 +113,7 @@ class QuotaExceededError extends Error {} * every upload. */ export async function getUserTotalStorageBytes(userId: string): Promise { - const [r2AssetRows, r2VideoRows, bunnyByUser, reservationRows] = await Promise.all([ + const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([ db.$queryRaw<[{ total: bigint }]>` SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total FROM video_assets @@ -82,7 +129,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise WHERE w."ownerId" = ${userId} AND vv."providerId" = 'r2' `, - getCachedUserBunnyStorage(), + getUserBunnyStorageBytes(userId), db.$queryRaw<[{ total: bigint }]>` SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total FROM upload_reservations @@ -93,7 +140,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0); const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0); - const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0); + const bunnyBytes = BigInt(bunnyUserBytes); const reservedBytes = reservationRows[0]?.total ?? BigInt(0); return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes; @@ -136,13 +183,13 @@ export async function enforceStorageQuota( return null; } - const [usedBytes, limitBytes] = await Promise.all([ + const [usedBytes, storage] = await Promise.all([ getUserTotalStorageBytes(userId), - getStorageLimitForUser(userId), + getStorageContextForUser(userId), ]); - if (usedBytes + incomingSizeBytes >= limitBytes) { - return apiErrors.storageExceeded() as NextResponse; + if (usedBytes + incomingSizeBytes >= storage.limitBytes) { + return storageExceededResponse(storage); } return null; @@ -176,11 +223,11 @@ export async function reserveStorageQuota( // Fetch Bunny storage and the account's ceiling BEFORE entering the transaction, // to avoid holding the advisory lock during a potentially slow/failing HTTP call // on cache miss or an extra round trip to Postgres. - const [bunnyData, limitBytes] = await Promise.all([ - getCachedUserBunnyStorage(), - getStorageLimitForUser(userId), + const [bunnyUserBytes, storage] = await Promise.all([ + getUserBunnyStorageBytes(userId), + getStorageContextForUser(userId), ]); - const bunnyBytes = BigInt(bunnyData[userId] ?? 0); + const bunnyBytes = BigInt(bunnyUserBytes); try { const reservationId = await db.$transaction(async (tx) => { @@ -222,7 +269,7 @@ export async function reserveStorageQuota( const reservedBytes = resRow?.total ?? BigInt(0); const totalUsed = r2Bytes + reservedBytes + bunnyBytes; - if (totalUsed + incomingSizeBytes >= limitBytes) { + if (totalUsed + incomingSizeBytes >= storage.limitBytes) { throw new QuotaExceededError(); } @@ -237,7 +284,7 @@ export async function reserveStorageQuota( return { reservationId }; } catch (e) { if (e instanceof QuotaExceededError) { - return { error: apiErrors.storageExceeded() as NextResponse }; + return { error: storageExceededResponse(storage) }; } throw e; } diff --git a/tests/api/lib-admin-stats.test.ts b/tests/api/lib-admin-stats.test.ts index fbb8df8..c05bf3d 100644 --- a/tests/api/lib-admin-stats.test.ts +++ b/tests/api/lib-admin-stats.test.ts @@ -36,6 +36,7 @@ import { getCachedUserBunnyStorage, getCachedUserDownloadEgress, getCachedUserMediaStorage, + getUserBunnyStorageBytes, refreshR2StorageSnapshot, } from '@/lib/admin-stats'; import { @@ -603,21 +604,23 @@ describe('getCachedUserBunnyStorage', () => { }); // 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 () => { + // that into "this user stores nothing", because lib/storage-quota.ts would then + // hand out headroom the user does not have. Bunny's figure is gone; the size + // declared when the upload was admitted is still in our own rows, so that is + // what the account is charged until Bunny can be reached again. + it('falls back to the declared sizes 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', + sizeBytes: BigInt(2048), }); bunnyCredentials(); stubBunnyPages([{ status: 503, body: {} }]); - expect(await getCachedUserBunnyStorage()).toEqual({}); + expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 2048 }); }); it('is empty on a database with no videos at all', async () => { @@ -987,3 +990,126 @@ describe('getCachedStripeStats', () => { expect(await getCachedStripeStats()).toBeNull(); }); }); + +// The per-user figure the quota checks read, which deliberately does not come +// from the cached map above. +// +// The bug this exists for: the declared size lands on the row at the moment an +// upload finalizes and the reservation is deleted in the same transaction. A map +// computed up to two minutes earlier does not have that row in it, so for those +// two minutes the upload that just succeeded counted as nothing. The uploader +// watched their usage fall back to zero and the next upload was measured against +// a total that ignored the one before it. +describe('getUserBunnyStorageBytes', () => { + it('counts a finished upload straight away, without waiting for Bunny', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + // Bunny has the video but reports nothing for it yet, which is what an + // encode in progress looks like. + stubBunnyLibrary({ 'bunny-encoding': 0 }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-encoding', + sizeBytes: BigInt(2_500_000_000), + }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000); + }); + + // What Bunny reports mid-encode is partial: it counts what has been written so + // far and climbs as each rendition lands. A 2.5 GB source read as 475 MB + // halfway through and settled above 3 GB once it finished. Letting the partial + // figure displace the declared size would hand most of the quota back in the + // middle of an encode, which is exactly what the declared size is there to stop. + it('keeps the declared size while Bunny figure is still climbing', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-encoding', + sizeBytes: BigInt(2_500_000_000), + }); + stubBunnyLibrary({ 'bunny-encoding': 474_900_000 }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000); + }); + + // And gets out of the way once the renditions are all there, because they are + // the actual bill and they add up to more than the source. + it('takes Bunny own figure once it passes the declared size', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-encoded', + sizeBytes: BigInt(2_500_000_000), + }); + stubBunnyLibrary({ 'bunny-encoded': 3_600_000_000 }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(3_600_000_000); + }); + + it('counts only the videos billed to the user asked about', async () => { + const mine = await seedProject(); + const theirs = await seedProject(); + const myVideo = await createVideo({ projectId: mine.project.id }); + const theirVideo = await createVideo({ projectId: theirs.project.id }); + await createVersion({ + videoParentId: myVideo.id, + providerId: 'bunny', + providerVideoId: 'bunny-mine', + sizeBytes: BigInt(700), + }); + await createVersion({ + videoParentId: theirVideo.id, + providerId: 'bunny', + providerVideoId: 'bunny-theirs', + sizeBytes: BigInt(900), + }); + stubBunnyLibrary({ 'bunny-mine': 0, 'bunny-theirs': 0 }); + + expect(await getUserBunnyStorageBytes(mine.owner.id)).toBe(700); + expect(await getUserBunnyStorageBytes(theirs.owner.id)).toBe(900); + }); + + it('adds Bunny assets to the user they are billed to, deduped against versions', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-shared', + sizeBytes: BigInt(500), + }); + await createVideoAsset({ + videoId: video.id, + billedUserId: scenario.owner.id, + kind: VideoAssetKind.VIDEO, + provider: VideoAssetProvider.BUNNY, + providerVideoId: 'bunny-shared', + sizeBytes: BigInt(500), + }); + await createVideoAsset({ + videoId: video.id, + billedUserId: scenario.owner.id, + kind: VideoAssetKind.VIDEO, + provider: VideoAssetProvider.BUNNY, + providerVideoId: 'bunny-asset-only', + sizeBytes: BigInt(300), + }); + stubBunnyLibrary({ 'bunny-shared': 0, 'bunny-asset-only': 0 }); + + // The shared id is one video however many rows point at it. + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(800); + }); + + it('is zero for a user with no Bunny videos', async () => { + const scenario = await seedProject(); + stubBunnyLibrary({ 'bunny-someone-else': 999 }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(0); + }); +}); diff --git a/tests/api/storage-quota.test.ts b/tests/api/storage-quota.test.ts index 916f24d..b41c2aa 100644 --- a/tests/api/storage-quota.test.ts +++ b/tests/api/storage-quota.test.ts @@ -12,7 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/lib/db'; -import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; +import { getUserBunnyStorageBytes } from '@/lib/admin-stats'; import { PLAN_STORAGE_LIMIT_BYTES, UPLOAD_RESERVATION_PURPOSES, @@ -41,7 +41,7 @@ import { const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024); function bunnyStorage(map: Record): void { - vi.mocked(getCachedUserBunnyStorage).mockResolvedValue(map); + vi.mocked(getUserBunnyStorageBytes).mockImplementation(async (userId) => map[userId] ?? 0); } // The mock implementation is module state, so it survives afterEach. Reset it so diff --git a/tests/setup/api.ts b/tests/setup/api.ts index adcff34..c91582e 100644 --- a/tests/setup/api.ts +++ b/tests/setup/api.ts @@ -146,14 +146,15 @@ vi.mock('@/lib/stripe', async (importOriginal) => { // --------------------------------------------------------------------------- // Bunny storage stats // --------------------------------------------------------------------------- -// getCachedUserBunnyStorage() is an HTTP call to the Bunny API, and it sits in -// the middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota -// suite overrides it to prove Bunny usage counts against the limit. +// Both of these reach the Bunny API, and getUserBunnyStorageBytes() sits in the +// middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota suite +// overrides them to prove Bunny usage counts against the limit. vi.mock('@/lib/admin-stats', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record), + getUserBunnyStorageBytes: vi.fn(async () => 0), }; });