|
| 1 | +/** |
| 2 | + * A deployment ships in at most one release, against the disposable TEST_DATABASE_URL database: |
| 3 | + * the workspace lock, the cross-release check, and concurrent publishes all run for real. |
| 4 | + */ |
| 5 | +import { db } from '@sim/db' |
| 6 | +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' |
| 7 | +import { createDeferred } from '@sim/testing/helpers/deferred' |
| 8 | +import { sleep } from '@sim/utils/helpers' |
| 9 | +import { generateId } from '@sim/utils/id' |
| 10 | +import postgres from 'postgres' |
| 11 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' |
| 12 | +import { |
| 13 | + type ChangelogChangeInput, |
| 14 | + insertReleaseInTx, |
| 15 | + updateRelease, |
| 16 | +} from '@/lib/changelog/repository' |
| 17 | + |
| 18 | +const control = postgres(readTestDatabaseUrl(), { max: 2, onnotice: () => {} }) |
| 19 | +const userId = generateId() |
| 20 | +const workspaceId = generateId() |
| 21 | +const workflowId = generateId() |
| 22 | +let patch = 0 |
| 23 | + |
| 24 | +async function seedDeployment(version: number): Promise<string> { |
| 25 | + const id = generateId() |
| 26 | + await control`INSERT INTO workflow_deployment_version (id, workflow_id, version, state) |
| 27 | + VALUES (${id}, ${workflowId}, ${version}, ${control.json({})})` |
| 28 | + return id |
| 29 | +} |
| 30 | + |
| 31 | +/** Publishes a release; with `holdOpen`, signals `written` and waits before committing. */ |
| 32 | +async function publish( |
| 33 | + changes: ChangelogChangeInput[], |
| 34 | + holdOpen?: { written: () => void; commit: Promise<void> } |
| 35 | +) { |
| 36 | + const bodyFileId = generateId() |
| 37 | + await control`INSERT INTO workspace_files (id, key, user_id, workspace_id, context, original_name, content_type, size_bytes) |
| 38 | + VALUES (${bodyFileId}, ${`workspace/${workspaceId}/${bodyFileId}`}, ${userId}, ${workspaceId}, 'changelog', |
| 39 | + ${`${bodyFileId}.md`}, 'text/markdown', 1)` |
| 40 | + patch += 1 |
| 41 | + return db.transaction(async (tx) => { |
| 42 | + const row = await insertReleaseInTx( |
| 43 | + tx, |
| 44 | + { |
| 45 | + id: generateId(), |
| 46 | + workspaceId, |
| 47 | + title: 'Fixture', |
| 48 | + versionMajor: 1, |
| 49 | + versionMinor: 0, |
| 50 | + versionPatch: patch, |
| 51 | + bumpReason: 'fixture', |
| 52 | + bodyFileId, |
| 53 | + }, |
| 54 | + changes |
| 55 | + ) |
| 56 | + if (holdOpen) { |
| 57 | + holdOpen.written() |
| 58 | + await holdOpen.commit |
| 59 | + } |
| 60 | + return row |
| 61 | + }) |
| 62 | +} |
| 63 | + |
| 64 | +/** Resolves once some session is blocked on an advisory lock. */ |
| 65 | +async function lockWaiterAppears(): Promise<void> { |
| 66 | + for (;;) { |
| 67 | + const waiting = |
| 68 | + await control`SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted` |
| 69 | + if (waiting.length > 0) return |
| 70 | + await sleep(10) |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +const change = (deploymentVersionId: string): ChangelogChangeInput => ({ |
| 75 | + text: 'fixture change', |
| 76 | + workflowId, |
| 77 | + deploymentVersionId, |
| 78 | +}) |
| 79 | + |
| 80 | +describe('A deployment ships in one release', () => { |
| 81 | + beforeAll(async () => { |
| 82 | + await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) |
| 83 | + VALUES (${userId}, 'Changelog fixture', ${`${userId}@example.test`}, true, now(), now())` |
| 84 | + await control`INSERT INTO workspace (id, name, owner_id, billed_account_user_id) |
| 85 | + VALUES (${workspaceId}, 'Changelog fixtures', ${userId}, ${userId})` |
| 86 | + await control`INSERT INTO workflow (id, user_id, workspace_id, name, last_synced, created_at, updated_at) |
| 87 | + VALUES (${workflowId}, ${userId}, ${workspaceId}, 'Fixture workflow', now(), now(), now())` |
| 88 | + }) |
| 89 | + |
| 90 | + afterAll(async () => { |
| 91 | + await control`DELETE FROM changelog_release WHERE workspace_id = ${workspaceId}` |
| 92 | + await control`DELETE FROM workspace WHERE id = ${workspaceId}` |
| 93 | + await control`DELETE FROM "user" WHERE id = ${userId}` |
| 94 | + await control.end() |
| 95 | + }) |
| 96 | + |
| 97 | + it('rejects a second release claiming a released deployment', async () => { |
| 98 | + const deployment = await seedDeployment(1) |
| 99 | + await publish([change(deployment)]) |
| 100 | + |
| 101 | + await expect(publish([change(deployment)])).rejects.toThrow(/Already released/) |
| 102 | + }) |
| 103 | + |
| 104 | + it('lets exactly one of two concurrent publishes claim a deployment', async () => { |
| 105 | + const deployment = await seedDeployment(2) |
| 106 | + const written = createDeferred<void>() |
| 107 | + const commit = createDeferred<void>() |
| 108 | + |
| 109 | + const first = publish([change(deployment)], { |
| 110 | + written: () => written.resolve(), |
| 111 | + commit: commit.promise, |
| 112 | + }) |
| 113 | + await written.promise |
| 114 | + const second = publish([change(deployment)]) |
| 115 | + await Promise.race([second.catch(() => {}), lockWaiterAppears()]) |
| 116 | + commit.resolve() |
| 117 | + |
| 118 | + const results = await Promise.allSettled([first, second]) |
| 119 | + |
| 120 | + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1) |
| 121 | + const claims = |
| 122 | + await control`SELECT 1 FROM changelog_change WHERE deployment_version_id = ${deployment}` |
| 123 | + expect(claims).toHaveLength(1) |
| 124 | + }) |
| 125 | + |
| 126 | + it('lets a release keep its own deployment when its changes are replaced', async () => { |
| 127 | + const deployment = await seedDeployment(3) |
| 128 | + const release = await publish([change(deployment)]) |
| 129 | + |
| 130 | + const updated = await updateRelease(release.id, release.revision, userId, {}, [ |
| 131 | + { ...change(deployment), text: 'reworded change' }, |
| 132 | + ]) |
| 133 | + |
| 134 | + expect(updated?.changes.map((c) => c.text)).toEqual(['reworded change']) |
| 135 | + }) |
| 136 | +}) |
0 commit comments