diff --git a/apps/web/src/app/api/video/pack/__tests__/route.test.ts b/apps/web/src/app/api/video/pack/__tests__/route.test.ts index 66da37fc9..aae9e69f5 100644 --- a/apps/web/src/app/api/video/pack/__tests__/route.test.ts +++ b/apps/web/src/app/api/video/pack/__tests__/route.test.ts @@ -44,6 +44,7 @@ afterEach(() => { extractVideoPackSpec.mockReset(); vi.resetModules(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); function postRequest(body: unknown) { @@ -119,6 +120,24 @@ describe('POST /api/video/pack', () => { finish?.(specFor(CANON_B)); }); + it('does not return 202 in production when Redis durability is unavailable', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('UPSTASH_REDIS_REST_URL', ''); + vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', ''); + vi.stubEnv('KV_REST_API_URL', ''); + vi.stubEnv('KV_REST_API_TOKEN', ''); + + const { POST, scheduled } = await loadPackRoute(); + const res = await POST(postRequest({ url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw' })); + + expect(res.status).toBe(503); + const body = (await res.json()) as { status?: string; error?: string }; + expect(body.status).toBe('error'); + expect(body.error).toMatch(/durable video pack storage is not configured/i); + expect(scheduled).toHaveLength(0); + expect(extractVideoPackSpec).not.toHaveBeenCalled(); + }); + it('fails closed with a visible error when Gateway extract is unavailable', async () => { extractVideoPackSpec.mockRejectedValue( new VideoPackExtractError( diff --git a/apps/web/src/lib/__tests__/video-pack-store.test.ts b/apps/web/src/lib/__tests__/video-pack-store.test.ts index d619587ae..3b41667ff 100644 --- a/apps/web/src/lib/__tests__/video-pack-store.test.ts +++ b/apps/web/src/lib/__tests__/video-pack-store.test.ts @@ -1,7 +1,7 @@ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; import net from 'node:net'; import { createClient, type RedisClientType } from 'redis'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { GOLDEN_IDENTITY_HASHES, applyExtractedSpec, buildIdentityPack } from '@/lib/video-pack'; import { PROCESSING_STALE_MS, @@ -117,9 +117,22 @@ function createRedis(initial: unknown = null) { afterEach(() => { resetVideoPackStoreForTests(); + vi.unstubAllEnvs(); }); describe('video-pack store', () => { + it('fails closed in production when Redis durability is unavailable', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('UPSTASH_REDIS_REST_URL', ''); + vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', ''); + vi.stubEnv('KV_REST_API_URL', ''); + vi.stubEnv('KV_REST_API_TOKEN', ''); + + await expect(claimPackProcessing(IDENTITY)).rejects.toThrow( + /durable video pack storage is not configured/i, + ); + }); + it('returns null for an unknown source_hash', async () => { expect(await getPackRecord(HASH)).toBeNull(); }); diff --git a/apps/web/src/lib/video-pack-store.ts b/apps/web/src/lib/video-pack-store.ts index 8457fc082..f23ab3b15 100644 --- a/apps/web/src/lib/video-pack-store.ts +++ b/apps/web/src/lib/video-pack-store.ts @@ -93,6 +93,14 @@ const memoryStore = new Map(); let redisPromise: Promise | null = null; +function assertDurableVideoPackStorageConfigured(): void { + if (process.env.NODE_ENV !== 'production') return; + if (resolveUpstashRedisCredentials()) return; + throw new Error( + 'Durable video pack storage is not configured in production. Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN (or KV_REST_API_URL + KV_REST_API_TOKEN).', + ); +} + export function packStoreKey(sourceHash: string): string { return `${VIDEO_PACK_STORE_PREFIX}${sourceHash}`; } @@ -209,6 +217,7 @@ export async function claimPackProcessing( identity: PackProcessingIdentity, now: Date = new Date(), ): Promise<'claimed' | VideoPackRecord> { + assertDurableVideoPackStorageConfigured(); const processing: Extract = { state: 'processing', video_id: identity.video_id, @@ -220,6 +229,9 @@ export async function claimPackProcessing( const key = packStoreKey(identity.source_hash); const redis = await getRedis(); + if (process.env.NODE_ENV === 'production' && !redis) { + throw new Error('Durable video pack storage is unavailable in production.'); + } if (redis) { try { const result = await redis.eval( diff --git a/apps/web/src/lib/video-pack.ts b/apps/web/src/lib/video-pack.ts index 15447b111..b4dabf9a6 100644 --- a/apps/web/src/lib/video-pack.ts +++ b/apps/web/src/lib/video-pack.ts @@ -356,12 +356,18 @@ export async function handleIdentityPackPost(request: Request): Promise>; + try { + claimed = await claimPackProcessing({ + video_id: identity.video_id, + source_url: identity.source_url, + source_hash: sourceHash, + id: identity.id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Video pack claim failed.'; + return NextResponse.json({ status: 'error', error: message }, { status: 503 }); + } if (claimed !== 'claimed') { return recordToResponse(claimed); }