Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 218 additions & 4 deletions packages/testing/src/postgres.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect, test } from 'bun:test'
import { fileURLToPath } from 'node:url'
import { afterAll, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

// Bundle the real wrapper with private, synthetic dependency modules. Unlike
// mock.module, these stubs cannot replace database modules in other test files.
Expand Down Expand Up @@ -36,8 +40,121 @@ const built = await Bun.build({
],
})
if (!built.success) throw new AggregateError(built.logs, 'Fixture test bundle failed')
const { configure, createIsolatedPostgres, withIsolatedPostgres } = await import(
`data:text/javascript;base64,${Buffer.from(await built.outputs[0].text()).toString('base64')}`

// The bundle is loaded from a temporary file this test owns.
//
// A module imported from a data: URL is not reportable by the coverage reporter
// of the pinned Bun 1.4.3: it aborts the whole coverage run with "panic: range
// end index 1884 out of range for slice of length 1024" before any summary is
// printed. The file holds the bundle's bytes exactly as the build produced them,
// so the fixture under test is unchanged.
//
// Every step after the directory is allocated can fail, and no failure may leave
// the directory behind: the allocation and the directory creation are one guarded
// step, the write is guarded as well as the import, and the removal is registered
// before the fixture is loaded. A removal that fails is reported next to the
// failure that caused it instead of replacing it.

/** The path of the fixture file inside an owned directory. */
function fixtureFilePath(directory) {
return join(directory, 'dist', 'postgres-fixture.mjs')
}

/** Removes an owned directory and everything in it. */
async function removeFixtureDirectory(directory) {
await rm(directory, { recursive: true, force: true })
}

/**
* The error a failure is reported as.
*
* The failure that caused the cleanup is kept, and a cleanup that failed too is
* reported beside it rather than in place of it.
*/
function fixtureFailure(error, cleanupError) {
if (!cleanupError) return error
return new AggregateError(
[error, cleanupError],
'the fixture failed and its cleanup failed too',
{
cause: error,
}
)
}

/**
* Runs a step that owns a directory, and removes that directory when the step fails.
*/
async function owningDirectory(directory, step, remove = removeFixtureDirectory) {
try {
return await step()
} catch (error) {
try {
await remove(directory)
} catch (cleanupError) {
throw fixtureFailure(error, cleanupError)
}
throw error
}
}

/**
* The directory that holds the fixture file.
*
* The allocation and the directory creation are one guarded step: a directory that
* was allocated and then not filled is removed instead of being left behind.
*/
async function createFixtureDirectory({
allocate = mkdtemp,
mkdir: makeDirectory = mkdir,
remove = removeFixtureDirectory,
} = {}) {
const directory = await allocate(join(tmpdir(), 'control-plane-postgres-fixture-'))
return owningDirectory(
directory,
async () => {
// The bundle is generated output, so it is written into a dist/ directory: the
// repository's coverage configuration already ignores generated output, and the
// coverage report keeps the rows it had before.
await makeDirectory(join(directory, 'dist'))
return directory
},
remove
)
}

/**
* Writes the fixture and loads it.
*
* The write is inside the guard too: a file that cannot be written leaves the owned
* directory behind unless the directory is removed here.
*/
async function loadFixtureModule(
directory,
source,
{ writeFile: write = writeFile, remove = removeFixtureDirectory } = {}
) {
return owningDirectory(
directory,
async () => {
await write(fixtureFilePath(directory), source)
return import(pathToFileURL(fixtureFilePath(directory)).href)
},
remove
)
}

const fixtureSource = await built.outputs[0].text()
const fixtureDirectory = await createFixtureDirectory()
const fixtureFile = fixtureFilePath(fixtureDirectory)
// The removal is registered before the fixture is loaded, so a failure while the
// fixture is written or imported still has a registered cleanup.
afterAll(async () => {
await removeFixtureDirectory(fixtureDirectory)
})
const { configure, createIsolatedPostgres, withIsolatedPostgres } = await loadFixtureModule(
fixtureDirectory,
fixtureSource
)

function fixture({ migrationError, cleanupError, creationError } = {}) {
Expand Down Expand Up @@ -129,3 +246,100 @@ test('operation failure still disposes exactly once', async () => {
).rejects.toBe(error)
expect(calls).toEqual(['migrate', 'operation', 'dispose'])
})

test('loads the fixture from the temporary file this test owns', async () => {
expect(existsSync(fixtureFile)).toBe(true)
expect(await readFile(fixtureFile, 'utf8')).toBe(fixtureSource)
expect(fixtureDirectory.startsWith(tmpdir())).toBe(true)
expect(typeof configure).toBe('function')
})

test('removes the temporary fixture directory it owns', async () => {
const directory = await createFixtureDirectory()
const file = fixtureFilePath(directory)
await writeFile(file, 'export const probe = 1\n')

expect(existsSync(file)).toBe(true)
await removeFixtureDirectory(directory)
expect(existsSync(directory)).toBe(false)
})

test('removes the owned directory when the fixture directory cannot be created', async () => {
const allocated = []
const failure = new Error('the fixture directory could not be created')
const error = await createFixtureDirectory({
allocate: async (prefix) => {
const directory = await mkdtemp(prefix)
allocated.push(directory)
return directory
},
mkdir: async () => {
throw failure
},
}).catch((thrown) => thrown)

expect(error).toBe(failure)
expect(allocated).toHaveLength(1)
expect(existsSync(allocated[0])).toBe(false)
})

test('removes the owned directory when the fixture file cannot be written', async () => {
const directory = await createFixtureDirectory()
const failure = new Error('the fixture file could not be written')
const error = await loadFixtureModule(directory, 'export const probe = 1\n', {
writeFile: async () => {
throw failure
},
}).catch((thrown) => thrown)

expect(error).toBe(failure)
expect(existsSync(directory)).toBe(false)
})

test('removes the owned directory when the fixture cannot be imported', async () => {
const directory = await createFixtureDirectory()
const error = await loadFixtureModule(directory, 'export const broken = (').catch(
(thrown) => thrown
)

expect(error).toBeInstanceOf(Error)
expect(existsSync(directory)).toBe(false)
})

test('keeps the original failure when the cleanup fails too', async () => {
const directory = await createFixtureDirectory()
const failure = new Error('the fixture file could not be written')
const cleanupFailure = new Error('the owned directory could not be removed')
const error = await loadFixtureModule(directory, 'export const probe = 1\n', {
writeFile: async () => {
throw failure
},
remove: async () => {
throw cleanupFailure
},
}).catch((thrown) => thrown)

expect(error).toBeInstanceOf(AggregateError)
expect(error.errors[0]).toBe(failure)
expect(error.errors[1]).toBe(cleanupFailure)
expect(error.cause).toBe(failure)
// The injected cleanup could not remove the directory, so the test removes it.
await removeFixtureDirectory(directory)
expect(existsSync(directory)).toBe(false)
})

test('leaves directories it does not own untouched', async () => {
const neighbour = await createFixtureDirectory()
const directory = await createFixtureDirectory()
const failure = new Error('the fixture file could not be written')

await loadFixtureModule(directory, 'export const probe = 1\n', {
writeFile: async () => {
throw failure
},
}).catch(() => {})

expect(existsSync(neighbour)).toBe(true)
expect(existsSync(directory)).toBe(false)
await removeFixtureDirectory(neighbour)
})
Loading