Template
1
0

feat: initial boilerplate

This commit is contained in:
2025-08-11 20:45:41 +02:00
parent d98524254f
commit 1215a98afc
148 changed files with 6935 additions and 2060 deletions

View File

@@ -0,0 +1,4 @@
export const config = {
mongodb: "mongo:8.0.3",
postgres: "postgres:17",
};

View File

@@ -0,0 +1,154 @@
import { getAvailablePort } from "@std/net";
import cookie from "cookie";
import { auth, Session } from "~libraries/auth/mod.ts";
import { Code } from "~libraries/code/aggregates/code.ts";
import { handler } from "~libraries/server/handler.ts";
import { Api, QueryMethod } from "../.generated/api.ts";
export class ApiTestContainer {
#server?: Deno.HttpServer;
#client?: Api;
#cookie?: string;
#session?: Session;
/*
|--------------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------------
*/
get accountId(): string | undefined {
if (this.#session?.valid === true) {
return this.#session.accountId;
}
}
get client() {
if (this.#client === undefined) {
throw new Error("ApiContainer > .start() has not been executed.");
}
return this.#client;
}
/*
|--------------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------------
*/
async start(): Promise<this> {
const port = await getAvailablePort();
this.#server = await Deno.serve({ port, hostname: "127.0.0.1" }, handler);
this.#client = makeApiClient(port, {
onBeforeRequest: (headers: Headers) => {
if (this.#cookie !== undefined) {
headers.set("cookie", this.#cookie);
}
},
onAfterResponse: (response) => {
const cookie = response.headers.get("set-cookie");
if (cookie !== null) {
this.#cookie = cookie;
}
},
});
return this;
}
async stop() {
await this.#server?.shutdown();
}
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
async authorize(accountId: string): Promise<void> {
const code = await Code.create({ identity: { type: "admin", accountId } }).save();
await this.client.auth.code(accountId, code.id, code.value, {});
this.#session = await this.getSession();
}
async getSession(): Promise<Session | undefined> {
const token = cookie.parse(this.#cookie ?? "").token;
if (token !== undefined) {
const session = await auth.resolve(token);
if (session.valid === true) {
return session;
}
}
}
unauthorize(): void {
this.#cookie = undefined;
}
}
function makeApiClient(
port: number,
{
onBeforeRequest,
onAfterResponse,
}: {
onBeforeRequest: (headers: Headers) => void;
onAfterResponse: (response: Response) => void;
},
): Api {
return new Api({
async command(payload) {
const headers = new Headers();
onBeforeRequest(headers);
headers.set("content-type", "application/json");
const response = await fetch(`http://127.0.0.1:${port}/api/v1/command`, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
const text = await response.text();
if (response.status >= 300) {
console.error(
`Command '${payload.method}' responded with error status '${response.status} ${response.statusText}'.`,
);
}
if (response.headers.get("content-type")?.includes("json") === true) {
return JSON.parse(text);
}
},
async query(method: QueryMethod, path: string, query: Record<string, unknown>, body: any = {}) {
const headers = new Headers();
onBeforeRequest(headers);
if (method !== "GET") {
headers.set("content-type", "application/json");
}
const response = await fetch(`http://127.0.0.1:${port}${path}${getSearchQuery(query)}`, {
method,
headers,
body: method === "GET" ? undefined : JSON.stringify(body),
});
onAfterResponse(response);
const text = await response.text();
if (response.status >= 300) {
console.error(`Query '${path}' responded with error status '${response.status} ${response.statusText}'.`);
throw new Error(response.statusText);
}
if (response.headers.get("content-type")?.includes("json") === true) {
return JSON.parse(text);
}
},
});
}
function getSearchQuery(query: Record<string, unknown>): string {
const search: string[] = [];
for (const key in query) {
search.push(`${key}=${query[key]}`);
}
if (search.length === 0) {
return "";
}
return `?${search.join("&")}`;
}

View File

@@ -0,0 +1,41 @@
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
import { container } from "~database/container.ts";
import { logger } from "~libraries/logger/mod.ts";
import { bootstrap } from "~libraries/utilities/bootstrap.ts";
import { API_DOMAINS_DIR, API_PACKAGES_DIR } from "~paths";
export class DatabaseTestContainer {
constructor(readonly mongo: MongoTestContainer) {
container.set("client", mongo.client);
}
/*
|--------------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------------
*/
async start(): Promise<this> {
logger.prefix("Database").info("DatabaseTestContainer Started");
await bootstrap(API_DOMAINS_DIR);
await bootstrap(API_PACKAGES_DIR);
return this;
}
async truncate() {
const promises: Promise<any>[] = [];
for (const dbName of ["balto:auth", "balto:code", "balto:consultant", "balto:task"]) {
const db = this.mongo.client.db(dbName);
const collections = await db.listCollections().toArray();
promises.push(...collections.map(({ name }) => db.collection(name).deleteMany({})));
}
await Promise.all(promises);
}
async stop() {
logger.prefix("Database").info("DatabaseTestContainer stopped");
}
}

View File

@@ -0,0 +1,178 @@
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
import { config } from "../config.ts";
import { ApiTestContainer } from "./api-container.ts";
import { DatabaseTestContainer } from "./database-container.ts";
export class TestContainer {
readonly id = crypto.randomUUID();
// ### Enablers
// A map of services to enable when the TestContainer is started. These toggles
// must be toggled before the container is started.
#with: With = {
mongodb: false,
database: false,
api: false,
};
// ### Needs
#needs: Needs = {
mongodb: [],
database: ["mongodb"],
api: ["mongodb", "database"],
};
// ### Services
// Any services that has been enabled will be running under the following
// assignments. Make sure to .stop any running services to avoid shutdown
// leaks.
#mongodb?: MongoTestContainer;
#database?: DatabaseTestContainer;
#api?: ApiTestContainer;
/*
|--------------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------------
*/
get accountId() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.accountId;
}
get mongodb(): MongoTestContainer {
if (this.#mongodb === undefined) {
throw new Error("TestContainer > .withMongo() must be called before starting the TestContainer.");
}
return this.#mongodb;
}
get database(): DatabaseTestContainer {
if (this.#database === undefined) {
throw new Error("TestContainer > .withDatabase() must be called before starting the TestContainer.");
}
return this.#database;
}
get api() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.client;
}
get authorize() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.authorize.bind(this.#api);
}
get unauthorize() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.unauthorize.bind(this.#api);
}
/*
|--------------------------------------------------------------------------------
| Builder
|--------------------------------------------------------------------------------
*/
withMongo(): this {
this.#with.mongodb = true;
return this;
}
withDatabase(): this {
this.#with.database = true;
return this;
}
withApi(): this {
this.#with.api = true;
return this;
}
/*
|--------------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------------
*/
async start(): Promise<this> {
const promises: Promise<void>[] = [];
if (this.#isNeeded("mongodb") === true) {
promises.push(
(async () => {
this.#mongodb = await MongoTestContainer.start(config.mongodb);
if (this.#isNeeded("database") === true) {
this.#database = await new DatabaseTestContainer(this.mongodb).start();
}
})(),
);
}
if (this.#isNeeded("api") === true) {
promises.push(
(async () => {
this.#api = await new ApiTestContainer().start();
})(),
);
}
await Promise.all(promises);
return this;
}
async stop(): Promise<this> {
await this.#api?.stop();
await this.#database?.stop();
await this.#mongodb?.stop();
this.#api = undefined;
this.#database = undefined;
this.#mongodb = undefined;
return this;
}
/*
|--------------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------------
*/
#isNeeded(target: keyof With): boolean {
if (this.#with[target] !== false) {
return true;
}
for (const key in this.#needs) {
if (this.#with[key as keyof With] !== false && this.#needs[key as keyof With].includes(target) === true) {
return true;
}
}
return false;
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Needs = Record<keyof With, (keyof With)[]>;
type With = {
mongodb: boolean;
database: boolean;
api: boolean;
};

View File

@@ -0,0 +1,24 @@
import * as assertSuite from "@std/assert";
import * as bddSuite from "@std/testing/bdd";
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
import { authorize } from "./utilities/account.ts";
export function describe(name: string, runner: TestRunner): (container: TestContainer) => void {
return (container: TestContainer) =>
bddSuite.describe(name, () => runner(container, bddSuite, assertSuite, { authorize: authorize(container) }));
}
export type TestRunner = (
container: TestContainer,
bdd: {
[key in keyof typeof bddSuite]: (typeof bddSuite)[key];
},
assert: {
[key in keyof typeof assertSuite]: (typeof assertSuite)[key];
},
utils: {
authorize: ReturnType<typeof authorize>;
},
) => void;

View File

@@ -0,0 +1,68 @@
import type { EventData } from "@valkyr/event-store";
import { AccountCreated, AccountEmailAdded } from "~libraries/auth/.generated/events.ts";
import { Account } from "~libraries/auth/aggregates/account.ts";
import { Role } from "~libraries/auth/aggregates/role.ts";
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
type AuthorizationOptions = {
name?: { family?: string; given?: string };
email?: Partial<EventData<AccountEmailAdded>>;
};
/**
* Return a function which provides the ability to create a new account which
* is authorized and ready to use for testing authorized requests.
*
* @param container - Container to authorize against.
*/
export function authorize(container: TestContainer): AuthorizeFn {
return async (data: EventData<AccountCreated>, { name = {}, email = {} }: AuthorizationOptions = {}) => {
const role = await makeRole(data.type).save();
const account = await Account.create(data, "test")
.addName(name?.family ?? "Doe", name?.given ?? "John", "test")
.addEmail({ value: "john.doe@fixture.none", type: "work", primary: true, verified: true, ...email }, "test")
.addRole(role.id, "test")
.save();
await container.authorize(account.id);
return account;
};
}
function makeRole(type: "admin" | "consultant" | "organization"): Role {
switch (type) {
case "admin": {
return Role.create(
{
name: "Admin",
permissions: [
{ resource: "admin", actions: ["create", "update", "delete"] },
{ resource: "consultant", actions: ["create", "update", "delete"] },
{ resource: "organization", actions: ["create", "update", "delete"] },
],
},
"test",
);
}
case "consultant": {
return Role.create(
{
name: "Consultant",
permissions: [{ resource: "consultant", actions: ["create", "update", "delete"] }],
},
"test",
);
}
case "organization": {
return Role.create(
{
name: "Organization",
permissions: [{ resource: "organization", actions: ["create", "update", "delete"] }],
},
"test",
);
}
}
}
type AuthorizeFn = (data: EventData<AccountCreated>, optional?: AuthorizationOptions) => Promise<Account>;