hazo_testing
v0.3.1
Published
Shared test infrastructure for hazo apps: SQLite test DB, real-package wiring, auth factories, apiTestClient, fixtures, Jest + Vitest presets.
Maintainers
Readme
hazo_testing
Shared test infrastructure for hazo apps. Gives a consumer's Jest suite:
- A real SQLite test database (in-memory
sql.jsviahazo_connect) with ordered migration application and teardown. - Auth factories that write real
hazo_authuser/scope rows and mint session cookies. - Real-package wiring helpers that stand up
hazo_jobs,hazo_notify, andhazo_llm_apiagainst the test DB — no fakes. - An
apiTestClient, seed fixtures, a Jest preset, and in-process reset hooks. - Optional sub-exports for Playwright and Next.js integration testing.
WARNING: real external I/O.
wireHazoNotifywithchannels: ["email"]or["telegram"]will send real email and real Telegram messages through ZeptoMail / Telegram Bot API. Tests that enable those channels are skip-gated on the presence ofZEPTOMAIL_API_KEY/ telegram credentials.wireHazoLlmApimakes real, billed API calls to Gemini / Qwen. Tests that callllm.run(...)are skip-gated onGEMINI_API_KEY/QWEN_API_KEY.- The inbox-only notify path and all jobs/fixture/factory tests need no credentials and run fully offline.
Install
npm install --save-dev hazo_testingPeer dependencies (install all that your test suite exercises):
npm install hazo_core hazo_connect hazo_auth hazo_jobs hazo_notify hazo_llm_apiQuick start
// your_suite.test.ts
import { createTestDatabase, createTestUser, seedTestData } from "hazo_testing";
let db: Awaited<ReturnType<typeof createTestDatabase>>;
beforeAll(async () => {
process.env.JWT_SECRET = "test-secret-min-32-chars-12345678";
db = await createTestDatabase({
setupSqlFiles: [
"node_modules/hazo_auth/db_setup_sqlite.sql",
],
});
await seedTestData(db.adapter, { fixture: "small_dataset" });
});
afterAll(() => db.teardown());createTestDatabase
import { createTestDatabase } from "hazo_testing";
const { adapter, baseUrl, teardown } = await createTestDatabase({
mode: "sqlite", // default — "docker" throws DockerNotSupported
setupSqlFiles: [ // full-schema .sql files, applied in array order
"node_modules/hazo_auth/db_setup_sqlite.sql",
"node_modules/hazo_jobs/db_setup_sqlite.sql",
"node_modules/hazo_notify/db_setup_sqlite.sql",
"./migrations/my_app_schema.sql",
],
migrations: [ // numbered-migration directories (NNNN_*.sql)
"./migrations",
],
});
// adapter — AugmentedAdapter (real SqliteAdapter + insertOne/findOne helpers)
// baseUrl — always null (SQLite mode has no HTTP endpoint)
// teardown() — disposes the in-memory DB
await teardown();- Each call creates an independent in-memory database — safe for parallel test workers.
mode: "docker"throwsDockerNotSupported(deferred to a future version).createTestDatabaseSqlite(opts)is an explicit-name alias.createTestDatabaseDocker()always throwsDockerNotSupported.
Augmented adapter
adapter is a HazoConnectAdapter augmented with two helper methods:
adapter.insertOne(table, row) // → T & { id: string }
adapter.findOne(table, where) // → T | nullThese are thin shims over createCrudService from hazo_connect. They handle UUID generation automatically. The underlying CrudService API is insert(row) → T[] and findOneBy(where) → T | null (no DbResult wrapper).
Auth factories
createTestUser
import { createTestUser } from "hazo_testing";
// Bare user — just a hazo_users row + session cookie (backward-compatible):
const user = await createTestUser(adapter, {
email: "[email protected]",
name: "Alice", // optional — defaults to email prefix
columnOverrides: { url_on_logon: "/home" }, // any nullable hazo_users column
});
// user.id — generated UUID
// user.email — "[email protected]"
// user.sessionCookie — ready "Cookie:" header string
// Permissioned user — seeds the full role/permission/scope chain in one call:
const admin = await createTestUser(adapter, {
email: "[email protected]",
role: "admin", // role name; auto-created if absent
permissions: ["system.access", "file.upload"], // permission names; auto-created
scopeId: "org-1", // scope id; auto-created if absent
scopeName: "Acme Corp", // optional — defaults to scopeId
scopeLevel: "firm", // optional — defaults to "firm"
});
// admin.roleId — id of the hazo_roles row
// admin.roleName — "admin"
// admin.permissions — ["system.access", "file.upload"]
// admin.scopeId — "org-1"- Writes a real
hazo_usersrow. Note: the column isemail_addressin the schema; the factory maps theemailoption automatically. - Mints a session cookie using HS256 JWT (same algorithm as
hazo_auth) signed withprocess.env.JWT_SECRET. - When
role,permissions, orscopeIdare given, seeds the full chain acrosshazo_roles,hazo_user_roles,hazo_permissions,hazo_role_permissions,hazo_scopes, andhazo_user_scopes. All rows are reused-or-created, so multiple calls sharing the same role/scopeId in one DB are idempotent. - Bare calls (no chain opts) return only
{ id, email, sessionCookie }— unchanged from v0.2.x.
createTestScope
import { createTestScope } from "hazo_testing";
const scope = await createTestScope(adapter, {
name: "Acme Corp",
level: "firm", // optional — defaults to "firm"; hazo_scopes.level is NOT NULL
members: [], // accepted but ignored in v0.1.0 (membership rows deferred)
});
// scope.id — generated UUID
// scope.name — "Acme Corp"- Writes a real
hazo_scopesrow. hazo_scopesrequireslevel TEXT NOT NULL— always supply a value or accept the"firm"default.hazo_scopeshas noowner_user_idcolumn. Theowner_user_idoption is accepted for future use but is not stored.
Wiring helpers
wireHazoJobs — zero external I/O
import { wireHazoJobs } from "hazo_testing";
const jobs = await wireHazoJobs(adapter);
const { jobId } = await jobs.submit({ type: "my-job", description: "test run", payload: { n: 1 } });
const worker = jobs.worker({ workerId: "w1", types: ["my-job"] });
await worker.run(async (job) => {
const payload = JSON.parse(job.payload as string); // payload is a raw JSON string
worker.stop(); // stop from INSIDE the handler
});
const row = await jobs.get(jobId);
expect(row.status).toBe("completed");- Applies hazo_jobs DDL and creates a
JobsClientagainst the test adapter. - Fully real, fully deterministic — pure DB rows and handler invocation.
worker.run()is a blocking poll loop — always callworker.stop()from inside the handler, not afterrun()awaits.job.payloadis a raw JSON string; callJSON.parse()inside the handler.- hazo_jobs DDL is loaded from the package root (
db_setup_sqlite.sql) viaimport.meta.resolvebecause the file is not in the package exports map. hazo_jobs/serveris imported via dynamicimport()to avoid a CJS/ESM module-load race under Jest's--experimental-vm-modulesrunner.
wireHazoNotify — real external sends when channels enabled
import { wireHazoNotify } from "hazo_testing";
// Inbox-only path — no credentials needed
const notify = await wireHazoNotify(adapter);
const result = await notify.dispatch({
scope_id: scope.id,
event_type: "test.event",
recipient_user_ids: [user.id],
in_app_text: "Hello",
deep_link: "/dashboard",
surfaces: ["inapp"],
channels: { inapp: true },
channel_payloads: {},
});
// Real email — requires ZEPTOMAIL_API_KEY
const notifyEmail = await wireHazoNotify(adapter, { channels: ["email"] });- Always auto-registers a no-op
inappstub channel so the inbox path works without credentials. Inbox table:hazo_notify_inbox. - Real email/telegram channels perform live I/O — skip-gate tests on env var presence.
__resetChannelRegistryis called automatically at the start of eachwireHazoNotifycall so tests can call it multiple times without "channel already registered" errors.- The
hazo_notify_inbox,hazo_notify_channel_deliveries,hazo_notify_preferences, and template tables must exist in the test database — includehazo_notify/db_setup_sqlite.sqlinsetupSqlFiles.
wireHazoLlmApi — real billed calls
import { wireHazoLlmApi } from "hazo_testing";
const llm = await wireHazoLlmApi({ providers: ["gemini"], primary: "gemini" });
// Assertions check shape, not text (non-deterministic)
const res = await llm.run({ prompt: "Say hello in one word" }, "gemini");
expect(res.success).toBe(true);
expect(typeof res.text).toBe("string");- Registers the real Gemini/Qwen providers with API keys read from environment variables.
- Provider constructors accept an empty
api_keywithout throwing — errors only surface at call time. This lets you asserttypeof llm.run === "function"without credentials. - Skip-gate tests that call
llm.run(...)onGEMINI_API_KEY/QWEN_API_KEY. - Fragile internal path:
clear_registry,register_provider,set_enabled_llms,set_primary_llm,GeminiProvider, andQwenProviderare not exported fromhazo_llm_api/server.wireHazoLlmApireaches them viaimport.meta.resolve("hazo_llm_api/server")→ dist root → dynamic import of internal dist paths. This will break ifhazo_llm_apirestructures its dist. Track FR-001 inhazo_llm_api/design/feature_requests/FR-001-export-provider-registry.md. initialize_llm_apiis called with a stubhazo_connect(api_log: { enabled: false }) so noprompt_library.sqliteis written to disk. If you see that file appearing, add it to your consumer's.gitignore.
apiTestClient
import { apiTestClient } from "hazo_testing";
const client = apiTestClient("http://localhost:3000", {
sessionCookie: user.sessionCookie, // optional
token: "bearer-token", // optional — injects Authorization header
});
const { status, body } = await client.get("/api/items");
const { status: s2 } = await client.post("/api/items", { name: "test" });
const { status: s3 } = await client.patch("/api/items/1", { name: "updated" });
const { status: s4 } = await client.delete("/api/items/1");
// File upload
const { status: s5 } = await client.upload("/api/files", {
file: Buffer.from("hello"),
mimeType: "text/plain",
fieldName: "file", // optional, default "file"
filename: "hello.txt", // optional
});
// SSE streaming
for await (const chunk of client.stream("/api/stream", { prompt: "..." })) {
process.stdout.write(chunk);
}
// Correlation ID from last response
console.log(client.lastCorrelationId);- Cookie jar is per-client instance and auto-rotates
Set-Cookieheaders. - Returns
{ status, body, headers }on every call.bodyis auto-parsed as JSON ifContent-Type: application/json.
Fixtures
import { seedTestData, registerFixture } from "hazo_testing";
// Built-in fixtures
await seedTestData(adapter, { fixture: "empty_with_one_user" });
await seedTestData(adapter, { fixture: "small_dataset" }); // ~5 users / 2 scopes
await seedTestData(adapter, { fixture: "large_dataset" }); // 500 users / 50 scopes
// Register a custom fixture
registerFixture("my_fixture", async (adapter) => {
const user = await createTestUser(adapter, { email: "[email protected]" });
return { users: [user] };
});
await seedTestData(adapter, { fixture: "my_fixture" });- Built-in fixtures write auth/scope rows only (S6 neutrality — no app-domain rows).
large_datasetis 500 users / 50 scopes in v0.1.0 (the spec's 5 000/500 was scaled down for in-memorysql.jsperformance).
Jest preset
// jest.config.cjs
module.exports = require("hazo_testing/jest-preset")({
aliasRoot: "<rootDir>/src", // optional — sets @/... alias; pass false to disable
setupFilesAfterEnv: [], // optional — additional setup files
});- Sets
testEnvironment: "jsdom",ts-jesttransform withisolatedModules: true. transformIgnorePatternsallowshazo_*packages to be transformed.- Injects polyfills (
TextEncoder,structuredClone,fetch) viasetupFiles. - Calls all registered reset hooks in
afterEachviasetupFilesAfterEnv. testTimeout: 10 000ms.jest-preset.cjsis CommonJS (the package istype: "module"but Jest config must be CJS).
Jest must run with:
{ "test": "NODE_OPTIONS=--experimental-vm-modules jest" }Without --experimental-vm-modules, ESM imports (hazo_notify, hazo_jobs, hazo_llm_api) will fail.
Vitest preset (0.2.0)
hazo_testing/vitest-preset is the Vitest counterpart to the Jest preset — an
ESM config factory. Additive: the Jest preset and all v0.1.x exports are
unchanged.
// vitest.config.ts
import { defineConfig } from "vitest/config";
import hazoTestingVitest from "hazo_testing/vitest-preset";
export default defineConfig(
hazoTestingVitest({
// alias: { ... }, // optional — extra resolve.alias entries
// setupFiles: [ ... ], // optional — additional setup files
}),
);test.environment: "node"— opt into jsdom per-file with a// @vitest-environment jsdomdocblock.setupFiles: ["hazo_testing/vitest-setup"]— runs registered reset hooks inafterEach(Vitest importsafterEachexplicitly; it is not a global).resolve.aliasmapsserver-only→ an empty stub so server modules import cleanly under Vitest.server.deps.inline: [/hazo_/]— hazo_* packages transform through Vite.testTimeout: 10 000ms.vitest-preset.mjsis ESM (Vitest reads config through Vite's ESM loader).
Install the optional peer: npm install --save-dev vitest.
renderWithIntl — hazo_testing/render
import { renderWithIntl } from "hazo_testing/render";
const { getByText } = await renderWithIntl(<MyComponent />, {
locale: "en", // default "en"
messages: { Greeting: { hi: "Hello" } }, // default {}
});Wraps ui in next-intl's NextIntlClientProvider and renders via
@testing-library/react. Both are optional peers, resolved lazily.
expectNoAxeViolations — hazo_testing/axe
import { expectNoAxeViolations } from "hazo_testing/axe";
await expectNoAxeViolations(container); // WCAG 2.0/2.1 A + AA by defaultThrows a formatted error listing any axe-core violations. axe-core ships as a
direct dependency.
Reset hooks
import { registerResetHook, runResetHooks } from "hazo_testing";
// Register a cleanup function to run after each test
registerResetHook(() => { myCache.clear(); });
// The preset calls runResetHooks() automatically in afterEach.
// __clearResetHooks() is available for test isolation if needed.Optional sub-exports
hazo_testing/playwright
import { hazoPage } from "hazo_testing/playwright";Provides @playwright/test fixtures. Requires the optional peer @playwright/test.
hazo_testing/nextjs
import { startNextServer } from "hazo_testing/nextjs";
const server = await startNextServer({ dir: "/path/to/app", port: 3999 });
// server.url — "http://127.0.0.1:3999"
await server.teardown();Boots a Next.js dev/prod server in-process. Requires the optional peer next. Port 0 binds to a random available port.
Test plan
The package's own suite (src/**/__tests__) covers:
| Scenario | Tests | Creds needed | Runs green locally |
|----------|-------|--------------|--------------------|
| db_lifecycle (sqlite) | 2–3 | none | yes |
| factories | 5 | none (JWT_SECRET defaulted) | yes |
| real_io — jobs | 3 | none | yes |
| real_io — notify | 2 | ZEPTOMAIL_API_KEY etc. | skip-gated |
| real_io — llm | 2 | GEMINI_API_KEY/QWEN_API_KEY | skip-gated |
| api_client | 5 | none (in-test handler) | yes |
| fixtures | 4 | none | yes |
Skip-gated tests print a clear it.skip reason when credentials are absent.
