tests/bundle-cleanup.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it, vi } from 'vitest'
import { planBundleCleanup } from '../supabase/functions/_backend/public/bundle/cleanup.ts'
import { lockBundleLifecycles, retryOnDeadlock, softDeleteLockedBundles } from '../supabase/functions/_backend/utils/bundle_soft_delete.ts'
// Newest first, the order the endpoint selects them in.
const versions = [
{ id: 6, name: '2.0.0', created_at: '2024-01-06T00:00:00Z' },
{ id: 5, name: '1.0.4', created_at: '2024-01-05T00:00:00Z' },
{ id: 4, name: '1.0.3', created_at: '2024-01-04T00:00:00Z' },
{ id: 3, name: 'builtin', created_at: '2024-01-03T00:00:00Z' },
{ id: 7, name: 'unknown', created_at: '2024-01-02T12:00:00Z' },
{ id: 2, name: '1.0.1', created_at: '2024-01-02T00:00:00Z' },
{ id: 1, name: '1.0.0', created_at: '2024-01-01T00:00:00Z' },
]
const names = (rows: { name: string }[]) => rows.map(row => row.name)
describe('planBundleCleanup', () => {
it('keeps the newest bundles and removes the rest, never touching placeholders', () => {
const plan = planBundleCleanup(versions, new Set(), { keep: 2, ignoreChannel: false })
expect(plan.active).toBe(5)
expect(plan.total).toBe(5)
expect(names(plan.kept)).toEqual(['2.0.0', '1.0.4'])
expect(names(plan.removed)).toEqual(['1.0.3', '1.0.1', '1.0.0'])
})
it('always keeps channel-linked bundles unless ignoreChannel is set', () => {
const linked = new Set([2])
const plan = planBundleCleanup(versions, linked, { keep: 1, ignoreChannel: false })
expect(names(plan.kept)).toEqual(['2.0.0', '1.0.1'])
expect(plan.kept.find(row => row.id === 2)?.linked).toBe(true)
expect(names(plan.removed)).toEqual(['1.0.4', '1.0.3', '1.0.0'])
const ignored = planBundleCleanup(versions, linked, { keep: 1, ignoreChannel: true })
expect(names(ignored.removed)).toEqual(['1.0.4', '1.0.3', '1.0.1', '1.0.0'])
})
it('limits cleanup to versions above `bundle` and below its next major', () => {
const plan = planBundleCleanup(versions, new Set(), { keep: 1, bundle: '1.0.0', ignoreChannel: false })
expect(plan.active).toBe(5)
expect(plan.total).toBe(3)
expect(names(plan.kept)).toEqual(['1.0.4'])
expect(names(plan.removed)).toEqual(['1.0.3', '1.0.1'])
})
it('keeps an empty range distinct from an app without bundles', () => {
const outOfRange = planBundleCleanup(versions, new Set(), { keep: 1, bundle: '7.0.0', ignoreChannel: false })
expect(outOfRange).toMatchObject({ active: 5, total: 0, kept: [], removed: [] })
const placeholdersOnly = planBundleCleanup(versions.filter(v => v.name === 'builtin' || v.name === 'unknown'), new Set(), { keep: 0, ignoreChannel: false })
expect(placeholdersOnly).toMatchObject({ active: 0, total: 0, removed: [] })
})
it('rejects an invalid `bundle` filter', () => {
expect(() => planBundleCleanup(versions, new Set(), { keep: 1, bundle: 'nope', ignoreChannel: false })).toThrow()
})
})
describe('retryOnDeadlock', () => {
const deadlock = Object.assign(new Error('deadlock detected'), { code: '40P01' })
it('retries a deadlock victim and returns the next result', async () => {
const fn = vi.fn().mockRejectedValueOnce(deadlock).mockResolvedValueOnce([1, 2])
await expect(retryOnDeadlock(fn)).resolves.toEqual([1, 2])
expect(fn).toHaveBeenCalledTimes(2)
})
it('rethrows any other error without retrying', async () => {
const failure = Object.assign(new Error('boom'), { code: '23505' })
const fn = vi.fn().mockRejectedValue(failure)
await expect(retryOnDeadlock(fn)).rejects.toBe(failure)
expect(fn).toHaveBeenCalledTimes(1)
})
it('gives up after the attempt budget', async () => {
const fn = vi.fn().mockRejectedValue(deadlock)
await expect(retryOnDeadlock(fn, 3)).rejects.toBe(deadlock)
expect(fn).toHaveBeenCalledTimes(3)
})
})
describe('bundle soft-delete helpers', () => {
function fakeTx(rows: unknown[] = []) {
const queries: { text: string, params?: unknown[] }[] = []
const tx = {
query: async (text: string, params?: unknown[]) => {
queries.push({ text, params })
return { rows: text.includes('UPDATE') ? rows : [] } as any
},
}
return { tx, queries }
}
it('locks every bundle through the lifecycle helper in id order', async () => {
const { tx, queries } = fakeTx()
await lockBundleLifecycles(tx, [9, 3])
expect(queries).toHaveLength(1)
expect(queries[0].text).toContain('lock_channel_bundle_lifecycle(id, NULL)')
expect(queries[0].text).toContain('ORDER BY id')
expect(queries[0].params).toEqual([[9, 3]])
})
it('attributes the write to the caller key, then deletes and returns numeric ids', async () => {
const { tx, queries } = fakeTx([{ id: '3' }, { id: 9 }])
await expect(softDeleteLockedBundles(tx, 'caller-key', 'com.test.app', [3, 9], false)).resolves.toEqual([3, 9])
expect(queries[0].text).toContain('set_config')
expect(queries[0].params).toEqual([JSON.stringify({ capgkey: 'caller-key' })])
expect(queries[1].text).toContain('UPDATE public.app_versions')
expect(queries[1].params).toEqual(['com.test.app', [3, 9], false])
})
})