import { randomUUID } from 'node:crypto' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { getCanonicalAppVersionR2Path } from '../supabase/functions/_backend/utils/app_version_r2_path.ts' import { BASE_URL, createAppVersions, getEndpointUrl, getSupabaseClient, headers, ORG_ID, resetAndSeedAppData, resetAppData, resetAppDataStats, warmEdgeEndpoint, insertManifestRows, } from './test-utils.ts' const id = randomUUID() const APPNAME = `com.app.c.${id}` let productionVersionId: number | null = null beforeAll(async () => { await resetAndSeedAppData(APPNAME) const { data } = await getSupabaseClient() .from('app_versions') .select('id') .eq('app_id', APPNAME) .eq('name', '1.0.0') .single() .throwOnError() productionVersionId = data.id await warmEdgeEndpoint('/bundle', { method: 'PUT', headers, body: JSON.stringify({ app_id: APPNAME }), }) }) afterEach(async () => { const client = getSupabaseClient() await client .from('channels') .update({ update_package: 'all', ...(productionVersionId ? { version: productionVersionId } : {}) }) .eq('app_id', APPNAME) .eq('name', 'production') .throwOnError() }) afterAll(async () => { await resetAppData(APPNAME) await resetAppDataStats(APPNAME) }) describe('[GET] /channel operations', () => { it('get channels', async () => { const params = new URLSearchParams({ app_id: APPNAME }) const response = await fetch(`${BASE_URL}/channel?${params.toString()}`, { method: 'GET', headers, }) const data = await response.json<{ name: string, ios: boolean, android: boolean }[]>() expect(response.status).toBe(200) expect(Array.isArray(data)).toBe(true) const { data: expected } = await getSupabaseClient() .from('channels') .select('ios, android') .eq('app_id', APPNAME) .eq('name', 'production') .single() .throwOnError() expect(data.find(channel => channel.name === 'production')).toMatchObject(expected) }) it('get specific channel', async () => { const params = new URLSearchParams({ app_id: APPNAME, channel: 'production' }) const response = await fetch(`${BASE_URL}/channel?${params.toString()}`, { method: 'GET', headers, }) const data = await response.json<{ name: string }>() expect(response.status).toBe(200) expect(data.name).toBe('production') const { data: expected } = await getSupabaseClient() .from('channels') .select('ios, android') .eq('app_id', APPNAME) .eq('name', 'production') .single() .throwOnError() expect(data).toMatchObject(expected) }) it('invalid app_id', async () => { const params = new URLSearchParams({ app_id: 'invalid_app' }) const response = await fetch(`${BASE_URL}/channel?${params.toString()}`, { method: 'GET', headers, }) await response.arrayBuffer() expect(response.status).toBe(400) }) }) describe('[POST] /channel operations', () => { it('create channel', async () => { const response = await fetch(getEndpointUrl('/channel'), { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: `test_channel_${id.slice(0, 8)}`, public: false, }), }) const data = await response.json<{ status: string, error?: string, message?: string }>() expect(response.status, JSON.stringify(data)).toBe(200) expect(data.status).toBe('ok') }) it('publicizes a private channel through POST /channel when caller has app.update_settings', async () => { const channelName = `publicize_${id.slice(0, 8)}` const channelEndpoint = getEndpointUrl('/channel') const client = getSupabaseClient() const createResponse = await fetch(channelEndpoint, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: channelName, public: false, }), }) const createData = await createResponse.json<{ status: string, error?: string, message?: string }>() expect(createResponse.status, JSON.stringify(createData)).toBe(200) const { data: productionBefore } = await client .from('channels') .select('public') .eq('app_id', APPNAME) .eq('name', 'production') .single() .throwOnError() try { if (productionBefore.public) { await client .from('channels') .update({ public: false }) .eq('app_id', APPNAME) .eq('name', 'production') .throwOnError() } const publicizeResponse = await fetch(channelEndpoint, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: channelName, public: true, }), }) const publicizeData = await publicizeResponse.json<{ status: string, error?: string, message?: string }>() expect(publicizeResponse.status, JSON.stringify(publicizeData)).toBe(200) expect(publicizeData.status).toBe('ok') const { data: channel } = await client .from('channels') .select('public') .eq('app_id', APPNAME) .eq('name', channelName) .single() .throwOnError() expect(channel.public).toBe(true) } finally { await client .from('channels') .update({ public: false }) .eq('app_id', APPNAME) .eq('name', channelName) .throwOnError() if (productionBefore.public) { await client .from('channels') .update({ public: true }) .eq('app_id', APPNAME) .eq('name', 'production') .throwOnError() } } }) it('update channel', async () => { const response = await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', disableAutoUpdateUnderNative: false, }), }) const data = await response.json<{ status: string }>() expect(response.status).toBe(200) expect(data.status).toBe('ok') }) it('update channel package mode', async () => { const response = await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', updatePackage: 'zip', }), }) const data = await response.json<{ status: string }>() expect(response.status).toBe(200) expect(data.status).toBe('ok') const getResponse = await fetch(`${BASE_URL}/channel?${new URLSearchParams({ app_id: APPNAME, channel: 'production' }).toString()}`, { method: 'GET', headers, }) const channel = await getResponse.json<{ updatePackage: string }>() expect(getResponse.status).toBe(200) expect(channel.updatePackage).toBe('zip') }) it('invalid app_id', async () => { const response = await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: 'invalid_app', channel: 'test_channel', }), }) await response.arrayBuffer() expect(response.status).toBe(400) }) }) describe('channel update package vs bundle compatibility', () => { async function insertDelta(versionId: number) { await insertManifestRows([{ app_version_id: versionId, file_name: `pkg-${versionId}.js`, s3_path: `/pkg-${versionId}.js`, file_hash: `hash-${versionId}`, file_size: 16, }]) } async function productionChannel() { const { data } = await getSupabaseClient() .from('channels') .select('id, version') .eq('app_id', APPNAME) .eq('name', 'production') .single() .throwOnError() return data } it('refuses delta-only when the current bundle has no delta files', async () => { const response = await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', updatePackage: 'delta', }), }) const data = await response.json<{ error?: string, message?: string }>() expect(response.status).toBe(400) expect(data.error).toBe('channel_delta_required') expect(data.message).toContain('no delta files') expect(data.message).toContain('1.0.0') }) it('refuses zip-only when assigning a delta-only bundle', async () => { const deltaOnly = await createAppVersions(`1.0.delta-${randomUUID().slice(0, 8)}`, APPNAME, { checksum: 'delta-only', storage_provider: 'r2-direct', r2_path: null, }) await insertDelta(deltaOnly.id) await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', updatePackage: 'zip', }), }) const response = await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', version: deltaOnly.name, }), }) const data = await response.json<{ error?: string, message?: string }>() expect(response.status).toBe(400) expect(data.error).toBe('channel_zip_required') expect(data.message).toContain('no zip') expect(data.message).toContain(deltaOnly.name) }) it('refuses assigning a zip-only bundle to a delta-only channel', async () => { const withDeltaName = `1.0.both-${randomUUID().slice(0, 8)}` const withDelta = await createAppVersions(withDeltaName, APPNAME, { checksum: 'zip-and-delta', storage_provider: 'r2', r2_path: getCanonicalAppVersionR2Path(ORG_ID, APPNAME, withDeltaName), }) await insertDelta(withDelta.id) await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', version: withDelta.name, updatePackage: 'delta', }), }) const zipOnlyName = `1.0.zip-${randomUUID().slice(0, 8)}` const zipOnly = await createAppVersions(zipOnlyName, APPNAME, { checksum: 'zip-only', storage_provider: 'r2', r2_path: getCanonicalAppVersionR2Path(ORG_ID, APPNAME, zipOnlyName), }) const response = await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', version: zipOnly.name, }), }) const data = await response.json<{ error?: string, message?: string }>() expect(response.status).toBe(400) expect(data.error).toBe('channel_delta_required') expect(data.message).toContain('no delta files') expect(data.message).toContain(zipOnly.name) }) it('refuses zip-only assignment through PUT /bundle', async () => { const channel = await productionChannel() const deltaOnly = await createAppVersions(`1.0.put-delta-${randomUUID().slice(0, 8)}`, APPNAME, { checksum: 'put-delta-only', storage_provider: 'r2-direct', r2_path: null, }) await insertDelta(deltaOnly.id) await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'production', updatePackage: 'zip', }), }) const response = await fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers, body: JSON.stringify({ app_id: APPNAME, version_id: deltaOnly.id, channel_id: channel.id, }), }) const data = await response.json<{ error?: string, message?: string }>() expect(response.status).toBe(400) expect(data.error).toBe('channel_zip_required') expect(data.message).toContain('no zip') }) it('refuses an incompatible setting change through a console-style channel update', async () => { const zipOnlyName = `1.0.ui-zip-${randomUUID().slice(0, 8)}` const zipOnly = await createAppVersions(zipOnlyName, APPNAME, { checksum: 'ui-zip-only', storage_provider: 'r2', r2_path: getCanonicalAppVersionR2Path(ORG_ID, APPNAME, zipOnlyName), }) await getSupabaseClient() .from('channels') .update({ version: zipOnly.id, update_package: 'all' }) .eq('app_id', APPNAME) .eq('name', 'production') .throwOnError() const { error } = await getSupabaseClient() .from('channels') .update({ update_package: 'delta' }) .eq('app_id', APPNAME) .eq('name', 'production') expect(error).toBeTruthy() expect(error?.message).toContain('CHANNEL_DELTA_REQUIRED') expect(error?.message).toContain('no delta files') }) }) describe('[DELETE] /channel operations', () => { it('invalid channel', async () => { const response = await fetch(`${BASE_URL}/channel`, { method: 'DELETE', headers, body: JSON.stringify({ channel: 'invalid_channel', app_id: APPNAME, }), }) await response.arrayBuffer() expect(response.status).toBe(400) }) it('delete channel', async () => { const response = await fetch(`${BASE_URL}/channel`, { method: 'DELETE', headers, body: JSON.stringify({ channel: 'production', app_id: APPNAME, }), }) const data = await response.json<{ status: string }>() expect(response.status).toBe(200) expect(data.status).toBe('ok') }) })