Initial Commit
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# @suaveplan/testing-integration-surrealdb - SurrealDB v3 testcontainers fixture for @suaveplan/testing-integration
|
||||
|
||||
## Summary
|
||||
|
||||
SurrealDB v3 testcontainers fixture for `@suaveplan/testing-integration`.
|
||||
|
||||
Implements `ServiceFixture<SurrealDBConfig>` — manages a SurrealDB container lifecycle with env-bridge passthrough, HTTP health checks, and random password generation per run.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add -D @suaveplan/testing-integration-surrealdb
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
|
||||
const fixture = new SurrealDBFixture();
|
||||
const config = await fixture.start();
|
||||
// config.url, config.username, config.password, config.namespace, config.database
|
||||
await fixture.stop();
|
||||
```
|
||||
|
||||
## Env-bridge passthrough
|
||||
|
||||
Set all three env vars to skip container startup:
|
||||
|
||||
```text
|
||||
SURREALDB_URL=ws://localhost:8000
|
||||
SURREALDB_USERNAME=root
|
||||
SURREALDB_PASSWORD=yourpassword
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- [SurrealDBFixture](src/fixture/surrealdb-fixture.md)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@suaveplan/testing-integration-surrealdb",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "SurrealDB v3 testcontainers fixture for @suaveplan/testing-integration",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./fixture/surrealdb-fixture": {
|
||||
"types": "./dist/fixture/surrealdb-fixture.d.ts",
|
||||
"default": "./dist/fixture/surrealdb-fixture.js"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"testcontainers": ">=11.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"testcontainers": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"testcontainers": "catalog:containers",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Default Docker image for SurrealDB. */
|
||||
export const SURREALDB_IMAGE = "surrealdb/surrealdb:v3.0.4";
|
||||
|
||||
/** Container port exposed by SurrealDB (HTTP + WebSocket). */
|
||||
export const SURREALDB_CONTAINER_PORT = 8000;
|
||||
|
||||
/** Default namespace for test databases. */
|
||||
export const SURREALDB_DEFAULT_NAMESPACE = "test";
|
||||
|
||||
/** Default database name for test databases. */
|
||||
export const SURREALDB_DEFAULT_DATABASE = "test";
|
||||
|
||||
/** Default username. */
|
||||
export const SURREALDB_DEFAULT_USERNAME = "root";
|
||||
|
||||
/** Health check poll interval in milliseconds. */
|
||||
export const SURREALDB_HEALTH_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
/** Maximum time to wait for SurrealDB to become healthy. */
|
||||
export const SURREALDB_HEALTH_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Log line testcontainers should wait for before treating the container as
|
||||
* ready. SurrealDB v3 emits this once at end of startup. Used in place of the
|
||||
* default `Wait.forListeningPorts()` strategy (which hangs under Bun per
|
||||
* oven-sh/bun#21342).
|
||||
*/
|
||||
export const SURREALDB_READY_LOG = "Started web server";
|
||||
|
||||
/** Number of `SURREALDB_READY_LOG` occurrences to wait for during a single startup. */
|
||||
export const SURREALDB_READY_LOG_TIMES = 1;
|
||||
|
||||
/** Env vars checked for passthrough mode — ALL must be set. */
|
||||
export const SURREALDB_ENV_VARS = [
|
||||
"SURREALDB_URL",
|
||||
"SURREALDB_USERNAME",
|
||||
"SURREALDB_PASSWORD",
|
||||
] as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { SurrealDBFixture } from "./surrealdb-fixture.js";
|
||||
export type {
|
||||
SurrealDBConfig,
|
||||
SurrealDBFixtureOptions,
|
||||
} from "./surrealdb-fixture.types.js";
|
||||
@@ -0,0 +1,112 @@
|
||||
# SurrealDBFixture
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SurrealDBFixture` is an integration test fixture that manages the full lifecycle of a SurrealDB v3 container using testcontainers. It implements the `ServiceFixture<SurrealDBConfig>` interface from `@suaveplan/testing-integration`, enabling consistent startup, health checking, and teardown as part of a `TestEnvironment` or standalone usage.
|
||||
|
||||
The fixture supports an env-bridge passthrough mode so developer workstations with live SurrealDB instances can skip container startup entirely — just set the three required environment variables and tests run against the real service without Docker overhead.
|
||||
|
||||
## Features
|
||||
|
||||
- **Env-bridge passthrough**: when `SURREALDB_URL`, `SURREALDB_USERNAME`, and `SURREALDB_PASSWORD` are all set and non-empty, container startup is bypassed and env values are returned directly.
|
||||
- **HTTP health check**: polls `GET /health` on SurrealDB's HTTP port (same port as WebSocket) to verify the service is ready before returning from `start()`.
|
||||
- **Random password per run**: generates a new `crypto.randomUUID()` password each time a container starts, preventing credential reuse across test suites.
|
||||
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without starting Docker, achieving 100% Istanbul coverage without integration dependencies.
|
||||
- **Configurable namespace and database**: override defaults via options to isolate test suites at the logical database level.
|
||||
- **Idempotent stop**: calling `stop()` on an already-stopped or passthrough fixture is safe and does not throw.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
|
||||
const fixture = new SurrealDBFixture();
|
||||
const config = await fixture.start();
|
||||
|
||||
// config.url → "ws://localhost:<randomPort>"
|
||||
// config.username → "root"
|
||||
// config.password → auto-generated UUID
|
||||
// config.namespace → "test"
|
||||
// config.database → "test"
|
||||
|
||||
await fixture.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import Surreal from "surrealdb";
|
||||
|
||||
const fixture = new SurrealDBFixture({
|
||||
image: "surrealdb/surrealdb:v3.0.4",
|
||||
namespace: "myapp",
|
||||
database: "integration",
|
||||
});
|
||||
|
||||
const config = await fixture.start();
|
||||
const db = new Surreal();
|
||||
await db.connect(config.url);
|
||||
await db.use({ namespace: config.namespace, database: config.database });
|
||||
await db.signin({ username: config.username, password: config.password });
|
||||
|
||||
// run tests…
|
||||
|
||||
await db.close();
|
||||
await fixture.stop();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SurrealDBFixture`
|
||||
|
||||
Implements `ServiceFixture<SurrealDBConfig>`.
|
||||
|
||||
#### `constructor(options?: SurrealDBFixtureOptions)`
|
||||
|
||||
Creates a new fixture instance. All options are optional.
|
||||
|
||||
#### `name: string`
|
||||
|
||||
Always `"surrealdb"`. Used by `TestEnvironment` as the registry key.
|
||||
|
||||
#### `start(): Promise<SurrealDBConfig>`
|
||||
|
||||
Starts the service. Returns the runtime configuration on success. If all three env vars are set, returns passthrough config immediately. Otherwise starts a testcontainers container and waits for the HTTP health endpoint.
|
||||
|
||||
#### `stop(): Promise<void>`
|
||||
|
||||
Stops the container. No-op in passthrough mode or when `start()` was never called.
|
||||
|
||||
#### `healthCheck(): Promise<boolean>`
|
||||
|
||||
Returns `true` when `GET http://localhost:<port>/health` returns an OK response. Returns `false` if no container is active or the request fails.
|
||||
|
||||
### `interface SurrealDBConfig`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `url` | `string` | WebSocket URL: `ws://localhost:<port>` |
|
||||
| `namespace` | `string` | SurrealDB namespace |
|
||||
| `database` | `string` | SurrealDB database name |
|
||||
| `username` | `string` | Auth username (always `root` in container mode) |
|
||||
| `password` | `string` | Auth password (random UUID in container mode) |
|
||||
|
||||
### `interface SurrealDBFixtureOptions`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `image?` | `string` | Docker image override. Defaults to `surrealdb/surrealdb:v3.0.4`. |
|
||||
| `namespace?` | `string` | Namespace override. Defaults to `"test"`. |
|
||||
| `database?` | `string` | Database override. Defaults to `"test"`. |
|
||||
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. When provided, replaces the real `GenericContainer` constructor. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Why HTTP health over WebSocket**: the `/health` HTTP endpoint is simpler to poll and does not require a SurrealDB client package in this fixture. WebSocket handshakes are stateful and require protocol negotiation; plain HTTP is sufficient for readiness signalling.
|
||||
- **Why random password per run**: reusing a static password across test runs creates a security surface for tests running in CI with shared Docker networks. UUID-generation via `crypto.randomUUID()` (the Web Crypto API, universally available in modern Bun and Node.js) is both fast and cryptographically random.
|
||||
- **Dynamic import of testcontainers**: `GenericContainer` is dynamically imported inside `start()` to keep the module side-effect-free and to allow the `containerFactory` injection path to bypass the import entirely for unit tests that provide their own factory.
|
||||
- **Port mapping**: testcontainers allocates an ephemeral host port for each `withExposedPorts()` declaration. `getMappedPort(8000)` retrieves the actual host port after the container starts.
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "./surrealdb-fixture.js";
|
||||
|
||||
function makeContainerFake(mappedPort = 9999) {
|
||||
const stopped: string[] = [];
|
||||
const fakeStarted = {
|
||||
getMappedPort: (_port: number) => mappedPort,
|
||||
stop: async () => {
|
||||
stopped.push("stopped");
|
||||
},
|
||||
};
|
||||
const fakeContainer = {
|
||||
withCommand: function () {
|
||||
return this;
|
||||
},
|
||||
withExposedPorts: function () {
|
||||
return this;
|
||||
},
|
||||
withWaitStrategy: function () {
|
||||
return this;
|
||||
},
|
||||
start: async () => fakeStarted,
|
||||
};
|
||||
return {
|
||||
fakeContainer,
|
||||
fakeStarted,
|
||||
stopped,
|
||||
factory: (_img: string) => fakeContainer as never,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SurrealDBFixture", () => {
|
||||
const ENV_KEYS = [
|
||||
"SURREALDB_URL",
|
||||
"SURREALDB_USERNAME",
|
||||
"SURREALDB_PASSWORD",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
});
|
||||
|
||||
it("name property returns surrealdb", { timeout: 5000 }, () => {
|
||||
const fixture = new SurrealDBFixture();
|
||||
expect(fixture.name).toBe("surrealdb");
|
||||
});
|
||||
|
||||
it("passthrough mode: returns config from env vars", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
process.env["SURREALDB_URL"] = "ws://localhost:8000";
|
||||
process.env["SURREALDB_USERNAME"] = "admin";
|
||||
process.env["SURREALDB_PASSWORD"] = "secret";
|
||||
|
||||
const fixture = new SurrealDBFixture();
|
||||
const config = await fixture.start();
|
||||
|
||||
expect(config.url).toBe("ws://localhost:8000");
|
||||
expect(config.username).toBe("admin");
|
||||
expect(config.password).toBe("secret");
|
||||
expect(config.namespace).toBe("test");
|
||||
expect(config.database).toBe("test");
|
||||
});
|
||||
|
||||
it("passthrough mode: stop is a no-op", { timeout: 30000 }, async () => {
|
||||
process.env["SURREALDB_URL"] = "ws://localhost:8000";
|
||||
process.env["SURREALDB_USERNAME"] = "admin";
|
||||
process.env["SURREALDB_PASSWORD"] = "secret";
|
||||
|
||||
const fixture = new SurrealDBFixture();
|
||||
await fixture.start();
|
||||
// Should not throw
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("passthrough mode: healthCheck returns false when no container", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
process.env["SURREALDB_URL"] = "ws://localhost:8000";
|
||||
process.env["SURREALDB_USERNAME"] = "admin";
|
||||
process.env["SURREALDB_PASSWORD"] = "secret";
|
||||
|
||||
const fixture = new SurrealDBFixture();
|
||||
await fixture.start();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("container mode happy path: returns correct config", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, fakeStarted } = makeContainerFake(9999);
|
||||
const fixture = new SurrealDBFixture({ containerFactory: factory });
|
||||
const config = await fixture.start();
|
||||
|
||||
expect(config.url).toBe("ws://localhost:9999");
|
||||
expect(config.username).toBe("root");
|
||||
expect(config.namespace).toBe("test");
|
||||
expect(config.database).toBe("test");
|
||||
expect(config.password).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(fakeStarted.getMappedPort(8000)).toBe(9999);
|
||||
});
|
||||
|
||||
it("container mode: stop calls container.stop()", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, stopped } = makeContainerFake(9999);
|
||||
const fixture = new SurrealDBFixture({ containerFactory: factory });
|
||||
await fixture.start();
|
||||
await fixture.stop();
|
||||
expect(stopped).toContain("stopped");
|
||||
});
|
||||
|
||||
it("container mode: healthCheck returns true when fetchFn returns ok", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory } = makeContainerFake(9999);
|
||||
const fixture = new SurrealDBFixture({
|
||||
containerFactory: factory,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
});
|
||||
await fixture.start();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("container mode: healthCheck returns false when fetchFn throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory } = makeContainerFake(9999);
|
||||
const fixture = new SurrealDBFixture({
|
||||
containerFactory: factory,
|
||||
fetchFn: async () => {
|
||||
throw new Error("Connection refused");
|
||||
},
|
||||
});
|
||||
await fixture.start();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("healthCheck returns false when no container", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fixture = new SurrealDBFixture();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("image override: factory called with custom image", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const calledWith: string[] = [];
|
||||
const { fakeContainer } = makeContainerFake(9999);
|
||||
const fixture = new SurrealDBFixture({
|
||||
image: "custom/surrealdb:latest",
|
||||
containerFactory: (img) => {
|
||||
calledWith.push(img);
|
||||
return fakeContainer as never;
|
||||
},
|
||||
});
|
||||
await fixture.start();
|
||||
expect(calledWith[0]).toBe("custom/surrealdb:latest");
|
||||
});
|
||||
|
||||
it("namespace and database overrides reflected in config", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory } = makeContainerFake(9999);
|
||||
const fixture = new SurrealDBFixture({
|
||||
namespace: "ns1",
|
||||
database: "db1",
|
||||
containerFactory: factory,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(config.namespace).toBe("ns1");
|
||||
expect(config.database).toBe("db1");
|
||||
});
|
||||
|
||||
it("generated password matches UUID format", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory } = makeContainerFake(9999);
|
||||
const fixture = new SurrealDBFixture({ containerFactory: factory });
|
||||
const config = await fixture.start();
|
||||
expect(config.password).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it("stop when no container is a no-op", { timeout: 30000 }, async () => {
|
||||
const fixture = new SurrealDBFixture();
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("start(): stops container and clears reference when getMappedPort throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stopped: string[] = [];
|
||||
const fakeStarted = {
|
||||
getMappedPort: (_port: number) => {
|
||||
throw new Error("port mapping failed");
|
||||
},
|
||||
stop: async () => {
|
||||
stopped.push("stopped");
|
||||
},
|
||||
};
|
||||
const fakeContainer = {
|
||||
withCommand: function () {
|
||||
return this;
|
||||
},
|
||||
withExposedPorts: function () {
|
||||
return this;
|
||||
},
|
||||
withWaitStrategy: function () {
|
||||
return this;
|
||||
},
|
||||
start: async () => fakeStarted,
|
||||
};
|
||||
|
||||
const fixture = new SurrealDBFixture({
|
||||
containerFactory: (_img: string) => fakeContainer as never,
|
||||
});
|
||||
|
||||
await expect(fixture.start()).rejects.toThrow("port mapping failed");
|
||||
expect(stopped).toContain("stopped");
|
||||
});
|
||||
|
||||
it("resolveContainerFactory: returns factory from dynamic import when no containerFactory", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Access protected method via subclass
|
||||
class Exposed extends SurrealDBFixture {
|
||||
resolveFactory() {
|
||||
return this.resolveContainerFactory();
|
||||
}
|
||||
}
|
||||
// No containerFactory provided — exercises the dynamic import branch
|
||||
const exposed = new Exposed();
|
||||
const resolved = await exposed.resolveFactory();
|
||||
expect(typeof resolved).toBe("function");
|
||||
// Invoke the returned factory to exercise the inner arrow function.
|
||||
// new GenericContainer(img) constructs the object; it does not start Docker.
|
||||
const container = resolved("surrealdb/surrealdb:v3.0.4");
|
||||
expect(container).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* SurrealDBFixture — testcontainers-backed ServiceFixture for SurrealDB v3.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
applyWaitStrategy,
|
||||
type ServiceFixture,
|
||||
} from "@suaveplan/testing-integration";
|
||||
import type { GenericContainer, StartedTestContainer } from "testcontainers";
|
||||
import {
|
||||
SURREALDB_CONTAINER_PORT,
|
||||
SURREALDB_DEFAULT_DATABASE,
|
||||
SURREALDB_DEFAULT_NAMESPACE,
|
||||
SURREALDB_DEFAULT_USERNAME,
|
||||
SURREALDB_ENV_VARS,
|
||||
SURREALDB_IMAGE,
|
||||
SURREALDB_READY_LOG,
|
||||
SURREALDB_READY_LOG_TIMES,
|
||||
} from "../constants/index.js";
|
||||
import type {
|
||||
SurrealDBConfig,
|
||||
SurrealDBFixtureOptions,
|
||||
} from "./surrealdb-fixture.types.js";
|
||||
|
||||
/**
|
||||
* Manages the full lifecycle of a SurrealDB v3 container for integration tests.
|
||||
*
|
||||
* Supports env-bridge passthrough mode: when `SURREALDB_URL`, `SURREALDB_USERNAME`,
|
||||
* and `SURREALDB_PASSWORD` are all set, the fixture returns them directly without
|
||||
* starting a container.
|
||||
*
|
||||
* Uses a random password generated via `crypto.randomUUID()` each run to avoid
|
||||
* credential reuse across test suites.
|
||||
*/
|
||||
export class SurrealDBFixture implements ServiceFixture<SurrealDBConfig> {
|
||||
readonly name = "surrealdb";
|
||||
|
||||
protected readonly options: SurrealDBFixtureOptions;
|
||||
protected container: StartedTestContainer | null = null;
|
||||
protected config: SurrealDBConfig | null = null;
|
||||
protected passthrough = false;
|
||||
|
||||
constructor(options: SurrealDBFixtureOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the container factory. When `containerFactory` is provided via
|
||||
* options, it is returned immediately. Otherwise performs a dynamic import
|
||||
* of testcontainers and returns a factory wrapping `GenericContainer`.
|
||||
*
|
||||
* Protected so subclasses and test doubles can override this seam.
|
||||
*/
|
||||
protected async resolveContainerFactory(): Promise<
|
||||
(image: string) => GenericContainer
|
||||
> {
|
||||
if (this.options.containerFactory) {
|
||||
return this.options.containerFactory;
|
||||
}
|
||||
const { GenericContainer } = await import("testcontainers");
|
||||
return (img: string) => new GenericContainer(img);
|
||||
}
|
||||
|
||||
async start(): Promise<SurrealDBConfig> {
|
||||
// Env-bridge passthrough mode — ALL three vars must be set and non-empty
|
||||
const [url, username, password] = SURREALDB_ENV_VARS.map(
|
||||
(v) => process.env[v] ?? ""
|
||||
);
|
||||
if (url && username && password) {
|
||||
this.passthrough = true;
|
||||
this.config = {
|
||||
url,
|
||||
namespace:
|
||||
this.options.namespace ?? SURREALDB_DEFAULT_NAMESPACE,
|
||||
database: this.options.database ?? SURREALDB_DEFAULT_DATABASE,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
return this.config;
|
||||
}
|
||||
|
||||
const generatedPassword = crypto.randomUUID();
|
||||
const image = this.options.image ?? SURREALDB_IMAGE;
|
||||
const factory = await this.resolveContainerFactory();
|
||||
const builder = factory(image)
|
||||
// Flag set ported from ../suaveplan/packages/universal/surrealdb/
|
||||
// docker-compose.test.yml — proven against v3.0.x. v3 dropped
|
||||
// `--auth` (always on; `--unauthenticated` to disable) and made
|
||||
// capabilities deny-by-default, so `--allow-all` is required for
|
||||
// tests that exercise funcs/net/scripting/guests. Trailing `memory`
|
||||
// selects the in-memory datastore explicitly.
|
||||
.withCommand([
|
||||
"start",
|
||||
"--log=info",
|
||||
`--user=${SURREALDB_DEFAULT_USERNAME}`,
|
||||
`--pass=${generatedPassword}`,
|
||||
"--allow-all",
|
||||
"memory",
|
||||
])
|
||||
.withExposedPorts(SURREALDB_CONTAINER_PORT);
|
||||
const raw = await applyWaitStrategy(builder, {
|
||||
readyLog: SURREALDB_READY_LOG,
|
||||
times: SURREALDB_READY_LOG_TIMES,
|
||||
});
|
||||
|
||||
this.container = await raw.start();
|
||||
|
||||
try {
|
||||
const port = this.container.getMappedPort(SURREALDB_CONTAINER_PORT);
|
||||
|
||||
this.config = {
|
||||
url: `ws://localhost:${port}`,
|
||||
namespace:
|
||||
this.options.namespace ?? SURREALDB_DEFAULT_NAMESPACE,
|
||||
database: this.options.database ?? SURREALDB_DEFAULT_DATABASE,
|
||||
username: SURREALDB_DEFAULT_USERNAME,
|
||||
password: generatedPassword,
|
||||
};
|
||||
return this.config;
|
||||
} catch (err) {
|
||||
await this.container.stop();
|
||||
this.container = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.passthrough || !this.container) return;
|
||||
await this.container.stop();
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.container) return false;
|
||||
const port = this.container.getMappedPort(SURREALDB_CONTAINER_PORT);
|
||||
try {
|
||||
const fetchFn =
|
||||
this.options.fetchFn ??
|
||||
/* istanbul ignore next */ globalThis.fetch;
|
||||
const res = await fetchFn(`http://localhost:${port}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { GenericContainer } from "testcontainers";
|
||||
|
||||
export interface SurrealDBConfig extends Record<string, unknown> {
|
||||
/** WebSocket URL: "ws://localhost:<port>" */
|
||||
url: string;
|
||||
namespace: string;
|
||||
database: string;
|
||||
username: string;
|
||||
/** Generated per run via crypto.randomUUID() */
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SurrealDBFixtureOptions {
|
||||
/** Override the Docker image. Defaults to SURREALDB_IMAGE constant. */
|
||||
image?: string;
|
||||
/** Override namespace. Defaults to "test". */
|
||||
namespace?: string;
|
||||
/** Override database. Defaults to "test". */
|
||||
database?: string;
|
||||
/**
|
||||
* Injectable GenericContainer constructor for unit testing.
|
||||
* When provided, the fixture uses this instead of importing from testcontainers.
|
||||
* This is the primary seam for unit tests — pass a fake container factory.
|
||||
*/
|
||||
containerFactory?: (image: string) => GenericContainer;
|
||||
/**
|
||||
* Injectable fetch function for unit testing healthCheck().
|
||||
* When provided, replaces globalThis.fetch. Mirrors the pattern used by
|
||||
* MailpitFixture, TypesenseFixture, and GarageS3Fixture.
|
||||
*/
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./constants/index.js";
|
||||
export { SurrealDBFixture } from "./fixture/surrealdb-fixture.js";
|
||||
export type {
|
||||
SurrealDBConfig,
|
||||
SurrealDBFixtureOptions,
|
||||
} from "./fixture/surrealdb-fixture.types.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto; File?: unknown };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
// testcontainers/undici requires File global in Node environments
|
||||
if (g.File === undefined) {
|
||||
g.File = class File extends Blob {
|
||||
readonly name: string;
|
||||
readonly lastModified: number;
|
||||
constructor(
|
||||
fileBits: BlobPart[],
|
||||
fileName: string,
|
||||
options?: FilePropertyBag
|
||||
) {
|
||||
super(fileBits, options);
|
||||
this.name = fileName;
|
||||
this.lastModified = options?.lastModified ?? Date.now();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"testcontainers",
|
||||
"@suaveplan/testing-integration",
|
||||
"node:net",
|
||||
"node:crypto",
|
||||
],
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
rollupTypes: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user