Skip to content

Commit

Permalink
feat: Merge pull request #29 from seamapi/feat-nested-hook-calls
Browse files Browse the repository at this point in the history
  • Loading branch information
codetheweb committed Apr 9, 2024
2 parents 3dd91f7 + ac66440 commit bc608af
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 60 deletions.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,55 @@ This works across the entire test suite.

Note that if unique parameters are passed to the `beforeTemplateIsBaked` (`null` in the above example), separate databases will still be created.

### Manual template creation

In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes. This is possible by using the `manuallyBuildAdditionalTemplate()` function passed to your hook callback:

```ts
import test from "ava"

const getTestDatabase = getTestPostgresDatabaseFactory<DatabaseParams>({
beforeTemplateIsBaked: async ({
params,
connection: { pool },
manuallyBuildAdditionalTemplate,
}) => {
await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`)

const fooTemplateBuilder = await manuallyBuildAdditionalTemplate()
await fooTemplateBuilder.connection.pool.query(
`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`
)
const { templateName: fooTemplateName } = await fooTemplateBuilder.finish()

return { fooTemplateName }
},
})

test("foo", async (t) => {
const barDatabase = await getTestDatabase({ type: "bar" })

// the "bar" database has the "bar" table...
await t.notThrowsAsync(async () => {
await barDatabase.pool.query(`SELECT * FROM "bar"`)
})

// ...but not the "foo" table...
await t.throwsAsync(async () => {
await barDatabase.pool.query(`SELECT * FROM "foo"`)
})

// ...and we can obtain a separate database with the "foo" table
const fooDatabase = await getTestDatabase.fromTemplate(
t,
barDatabase.beforeTemplateIsBakedResult.fooTemplateName
)
await t.notThrowsAsync(async () => {
await fooDatabase.pool.query(`SELECT * FROM "foo"`)
})
})
```

### Bind mounts & `exec`ing in the container

`ava-postgres` uses [testcontainers](https://www.npmjs.com/package/testcontainers) under the hood to manage the Postgres container.
Expand Down
133 changes: 82 additions & 51 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import type {
GetTestPostgresDatabase,
GetTestPostgresDatabaseFactoryOptions,
GetTestPostgresDatabaseOptions,
GetTestPostgresDatabaseResult,
} from "./public-types"
import { Pool } from "pg"
import type { Jsonifiable } from "type-fest"
import type { ExecutionContext } from "ava"
import { once } from "node:events"
import { createBirpc } from "birpc"
import { BirpcReturn, createBirpc } from "birpc"
import { ExecResult } from "testcontainers"
import isPlainObject from "lodash/isPlainObject"

Expand Down Expand Up @@ -105,7 +105,7 @@ export const getTestPostgresDatabaseFactory = <
Params extends Jsonifiable = never
>(
options?: GetTestPostgresDatabaseFactoryOptions<Params>
) => {
): GetTestPostgresDatabase<Params> => {
const initialData: InitialWorkerData = {
postgresVersion: options?.postgresVersion ?? "14",
containerOptions: options?.container,
Expand Down Expand Up @@ -136,57 +136,73 @@ export const getTestPostgresDatabaseFactory = <
}

let rpcCallback: (data: any) => void
const rpc = createBirpc<SharedWorkerFunctions, TestWorkerFunctions>(
{
runBeforeTemplateIsBakedHook: async (connection, params) => {
if (options?.beforeTemplateIsBaked) {
const connectionDetails =
mapWorkerConnectionDetailsToConnectionDetails(connection)

// Ignore if the pool is terminated by the shared worker
// (This happens in CI for some reason even though we drain the pool first.)
connectionDetails.pool.on("error", (error) => {
if (
error.message.includes(
"terminating connection due to administrator command"
)
) {
return
}
const rpc: BirpcReturn<SharedWorkerFunctions, TestWorkerFunctions> =
createBirpc<SharedWorkerFunctions, TestWorkerFunctions>(
{
runBeforeTemplateIsBakedHook: async (connection, params) => {
if (options?.beforeTemplateIsBaked) {
const connectionDetails =
mapWorkerConnectionDetailsToConnectionDetails(connection)

throw error
})
// Ignore if the pool is terminated by the shared worker
// (This happens in CI for some reason even though we drain the pool first.)
connectionDetails.pool.on("error", (error) => {
if (
error.message.includes(
"terminating connection due to administrator command"
)
) {
return
}

const hookResult = await options.beforeTemplateIsBaked({
params: params as any,
connection: connectionDetails,
containerExec: async (command): Promise<ExecResult> =>
rpc.execCommandInContainer(command),
})
throw error
})

await teardownConnection(connectionDetails)
const hookResult = await options.beforeTemplateIsBaked({
params: params as any,
connection: connectionDetails,
containerExec: async (command): Promise<ExecResult> =>
rpc.execCommandInContainer(command),
// This is what allows a consumer to get a "nested" database from within their beforeTemplateIsBaked hook
manuallyBuildAdditionalTemplate: async () => {
const connection =
mapWorkerConnectionDetailsToConnectionDetails(
await rpc.createEmptyDatabase()
)

if (hookResult && !isSerializable(hookResult)) {
throw new TypeError(
"Return value of beforeTemplateIsBaked() hook could not be serialized. Make sure it returns only JSON-serializable values."
)
}
return {
connection,
finish: async () => {
await teardownConnection(connection)
return rpc.convertDatabaseToTemplate(connection.database)
},
}
},
})

return hookResult
}
},
},
{
post: async (data) => {
const worker = await workerPromise
await worker.available
worker.publish(data)
},
on: (data) => {
rpcCallback = data
await teardownConnection(connectionDetails)

if (hookResult && !isSerializable(hookResult)) {
throw new TypeError(
"Return value of beforeTemplateIsBaked() hook could not be serialized. Make sure it returns only JSON-serializable values."
)
}

return hookResult
}
},
},
}
)
{
post: async (data) => {
const worker = await workerPromise
await worker.available
worker.publish(data)
},
on: (data) => {
rpcCallback = data
},
}
)

// Automatically cleaned up by AVA since each test file runs in a separate worker
const _messageHandlerPromise = (async () => {
Expand All @@ -198,11 +214,11 @@ export const getTestPostgresDatabaseFactory = <
}
})()

const getTestPostgresDatabase: GetTestPostgresDatabase<Params> = async (
const getTestPostgresDatabase = async (
t: ExecutionContext,
params: any,
getTestDatabaseOptions?: GetTestPostgresDatabaseOptions
) => {
): Promise<GetTestPostgresDatabaseResult> => {
const testDatabaseConnection = await rpc.getTestDatabase({
databaseDedupeKey: getTestDatabaseOptions?.databaseDedupeKey,
params,
Expand All @@ -223,7 +239,22 @@ export const getTestPostgresDatabaseFactory = <
}
}

return getTestPostgresDatabase
getTestPostgresDatabase.fromTemplate = async (
t: ExecutionContext,
templateName: string
) => {
const connection = mapWorkerConnectionDetailsToConnectionDetails(
await rpc.createDatabaseFromTemplate(templateName)
)

t.teardown(async () => {
await teardownConnection(connection)
})

return connection
}

return getTestPostgresDatabase as any
}

export * from "./public-types"
7 changes: 7 additions & 0 deletions src/internal-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,11 @@ export interface SharedWorkerFunctions {
beforeTemplateIsBakedResult: unknown
}>
execCommandInContainer: (command: string[]) => Promise<ExecResult>
createEmptyDatabase: () => Promise<ConnectionDetailsFromWorker>
createDatabaseFromTemplate: (
templateName: string
) => Promise<ConnectionDetailsFromWorker>
convertDatabaseToTemplate: (
databaseName: string
) => Promise<{ templateName: string }>
}
76 changes: 69 additions & 7 deletions src/public-types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Pool } from "pg"
import type { Jsonifiable } from "type-fest"
import { ExecutionContext } from "ava"
import { ExecResult } from "testcontainers"
import { BindMode } from "testcontainers/build/types"
import type { ExecutionContext } from "ava"
import type { ExecResult } from "testcontainers"
import type { BindMode } from "testcontainers/build/types"

export interface ConnectionDetails {
connectionString: string
Expand Down Expand Up @@ -58,6 +58,59 @@ export interface GetTestPostgresDatabaseFactoryOptions<
connection: ConnectionDetails
params: Params
containerExec: (command: string[]) => Promise<ExecResult>
/**
* In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes.
*
* @example
* ```ts
* import test from "ava"
*
* const getTestDatabase = getTestPostgresDatabaseFactory<DatabaseParams>({
* beforeTemplateIsBaked: async ({
* params,
* connection: { pool },
* manuallyBuildAdditionalTemplate,
* }) => {
* await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`)
*
* const fooTemplateBuilder = await manuallyBuildAdditionalTemplate()
* await fooTemplateBuilder.connection.pool.query(
* `CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`
* )
* const { templateName: fooTemplateName } = await fooTemplateBuilder.finish()
*
* return { fooTemplateName }
* },
* })
*
* test("foo", async (t) => {
* const barDatabase = await getTestDatabase({ type: "bar" })
*
* // the "bar" database has the "bar" table...
* await t.notThrowsAsync(async () => {
* await barDatabase.pool.query(`SELECT * FROM "bar"`)
* })
*
* // ...but not the "foo" table...
* await t.throwsAsync(async () => {
* await barDatabase.pool.query(`SELECT * FROM "foo"`)
* })
*
* // ...and we can obtain a separate database with the "foo" table
* const fooDatabase = await getTestDatabase.fromTemplate(
* t,
* barDatabase.beforeTemplateIsBakedResult.fooTemplateName
* )
* await t.notThrowsAsync(async () => {
* await fooDatabase.pool.query(`SELECT * FROM "foo"`)
* })
* })
* ```
*/
manuallyBuildAdditionalTemplate: () => Promise<{
connection: ConnectionDetails
finish: () => Promise<{ templateName: string }>
}>
}) => Promise<any>
}

Expand Down Expand Up @@ -94,14 +147,23 @@ export type GetTestPostgresDatabaseOptions = {
// https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887
type IsNeverType<T> = [T] extends [never] ? true : false

interface BaseGetTestPostgresDatabase {
fromTemplate(
t: ExecutionContext,
templateName: string
): Promise<ConnectionDetails>
}

export type GetTestPostgresDatabase<Params> = IsNeverType<Params> extends true
? (
? ((
t: ExecutionContext,
args?: null,
options?: GetTestPostgresDatabaseOptions
) => Promise<GetTestPostgresDatabaseResult>
: (
) => Promise<GetTestPostgresDatabaseResult>) &
BaseGetTestPostgresDatabase
: ((
t: ExecutionContext,
args: Params,
options?: GetTestPostgresDatabaseOptions
) => Promise<GetTestPostgresDatabaseResult>
) => Promise<GetTestPostgresDatabaseResult>) &
BaseGetTestPostgresDatabase
38 changes: 38 additions & 0 deletions src/tests/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,41 @@ test("beforeTemplateIsBaked (result isn't serializable)", async (t) => {
}
)
})

test("beforeTemplateIsBaked with manual template build", async (t) => {
const getTestDatabase = getTestPostgresDatabaseFactory({
postgresVersion: process.env.POSTGRES_VERSION,
workerDedupeKey: "beforeTemplateIsBakedHookManualTemplateBuild",
beforeTemplateIsBaked: async ({
connection: { pool },
manuallyBuildAdditionalTemplate,
}) => {
await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`)

const fooTemplateBuilder = await manuallyBuildAdditionalTemplate()
await fooTemplateBuilder.connection.pool.query(
`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`
)
const { templateName: fooTemplateName } =
await fooTemplateBuilder.finish()

return { fooTemplateName }
},
})

const barDatabase = await getTestDatabase(t)
t.truthy(barDatabase.beforeTemplateIsBakedResult.fooTemplateName)

const fooDatabase = await getTestDatabase.fromTemplate(
t,
barDatabase.beforeTemplateIsBakedResult.fooTemplateName
)

await t.notThrowsAsync(async () => {
await fooDatabase.pool.query('SELECT * FROM "foo"')
}, "foo table should exist on database manually created from template")

await t.throwsAsync(async () => {
await fooDatabase.pool.query('SELECT * FROM "bar"')
})
})

0 comments on commit bc608af

Please sign in to comment.