Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkgRoot = resolve(__dirname, "..");
const srcRoot = join(pkgRoot, "src");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
describe("@suaveplan/api-plugin-user dep graph + layout", () => {
it("package.json carries required dependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as {
dependencies: Record<string, string>;
};
const deps = pkg.dependencies;
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
expect(deps["@suaveplan/api-plugin-auth"]).toBe("workspace:^");
expect(deps["@suaveplan/api-openapi"]).toBe("workspace:^");
expect(deps["@suaveplan/api-migrations"]).toBe("workspace:^");
expect(deps["@suaveplan/error"]).toBe("workspace:^");
expect(deps["@suaveplan/types"]).toBe("workspace:^");
expect(deps["hono"]).toBe("catalog:");
expect(deps["@hono/zod-openapi"]).toBe("catalog:server");
expect(deps["zod"]).toBe("catalog:forms");
expect(deps["better-auth"]).toBe("catalog:auth");
expect(deps["@opentelemetry/api"]).toBe("catalog:observability");
});
it("package.json exposes 9 feature subpath exports", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const subs = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json" && k !== "."
);
// 9 feature subpaths (plugin, services/user-service, schemas/options,
// schemas/user-profile, routes/routes, schema/schema,
// error-codes/error-codes, otel/instruments, types/types) — the
// "." root export is excluded by the filter above.
expect(subs.length).toBe(9);
});
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
const missing: string[] = [];
for (const file of walk(srcRoot)) {
if (!file.endsWith(".ts")) continue;
if (
file.endsWith(".test.ts") ||
file.endsWith(".spec.ts") ||
file.endsWith(".types.ts") ||
file.endsWith("/index.ts")
) {
continue;
}
const md = file.replace(/\.ts$/, ".md");
try {
statSync(md);
} catch {
missing.push(md);
}
}
expect(missing).toEqual([]);
});
it("README.md is present at package root", { timeout: 5000 }, () => {
const readme = readFileSync(join(pkgRoot, "README.md"), "utf8");
expect(readme.length).toBeGreaterThan(200);
expect(readme).toContain("@suaveplan/api-plugin-user");
});
it("every subpath export resolves to a file within src/", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as {
exports: Record<string, { import?: { default?: string } } | string>;
};
const missing: string[] = [];
for (const [sub, val] of Object.entries(pkg.exports)) {
if (sub === "./package.json") continue;
const defaultPath =
typeof val === "string"
? val
: (val?.import?.default ?? undefined);
if (defaultPath === undefined) continue;
const rel = defaultPath
.replace(/^\.\/dist\//, "")
.replace(/\.js$/, ".ts");
const srcPath = join(srcRoot, rel);
try {
statSync(srcPath);
} catch {
missing.push(`${sub} → ${srcPath}`);
}
}
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,160 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import {
type AuthPluginConfig,
createAuthPlugin,
} from "@suaveplan/api-plugin-auth";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { EventBus } from "@suaveplan/events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { createUserPlugin } from "../../src/plugin/plugin.js";
import type { UserPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
await client.execute(
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
);
await client.execute(
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
);
}, 180_000);
afterAll(async () => {
if (client) {
await client.disconnect();
client = null;
}
if (fixture) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("user plugin full-boot integration", () => {
it("sign-up → /users/me → PATCH → DELETE roundtrip against real SurrealDB", {
timeout: 120_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "user-test", version: "0.1.0" },
});
// Mount auth plugin first.
const authPlugin = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000/auth",
emailAndPassword: { enabled: true },
user: { deleteUser: { enabled: true } },
},
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("db", client);
await authPlugin.onInit?.(
createPluginContext<AuthPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: authPlugin.defaults as AuthPluginConfig,
services,
})
);
// Mount user plugin.
const userPlugin = createUserPlugin({});
await userPlugin.onInit?.(
createPluginContext<UserPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: userPlugin.defaults as UserPluginConfig,
services,
})
);
registry.applyTo(app, "/openapi.json");
// Sign up via Better-Auth API.
const authServiceSlot = services.get("authService") as {
getBetterAuthInstance: () => {
api: {
signUpEmail: (a: {
body: { email: string; password: string; name: string };
}) => Promise<{ user: { id: string } }>;
};
};
};
const auth = authServiceSlot.getBetterAuthInstance();
const signUp = await auth.api.signUpEmail({
body: {
email: "user-plugin@example.test",
password: "Passw0rd!2026",
name: "Test User",
},
});
expect(signUp.user.id.length).toBeGreaterThan(0);
// userService.getById round-trips against the live row.
const userService = services.get("userService") as {
getById: (id: string) => Promise<unknown>;
updateProfile: (
id: string,
patch: Readonly<Record<string, unknown>>
) => Promise<{ displayName?: string }>;
};
const profile = await userService.getById(signUp.user.id);
expect(profile).not.toBeNull();
// PATCH applies and refresh returns updated value.
const updated = await userService.updateProfile(signUp.user.id, {
displayName: "Test Updated",
});
expect(updated.displayName).toBe("Test Updated");
// OpenAPI doc lists /users/me three times (get, patch, delete).
const openapiRes = await app.request("/openapi.json");
const doc = (await openapiRes.json()) as {
paths: Record<string, Record<string, unknown>>;
};
const meMethods = Object.keys(doc.paths?.["/users/me"] ?? {});
expect(meMethods.sort()).toEqual(["delete", "get", "patch"]);
});
});

View File

@@ -0,0 +1,64 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import type { AuthService } from "@suaveplan/api-plugin-auth";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createUserPlugin } from "../../src/plugin/plugin.js";
import type { UserPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function fakeAuthService(): AuthService {
return {
getBetterAuthInstance: () =>
({ $context: Promise.resolve({}) }) as never,
getCurrentPrincipal: () => null,
getSession: async () => null,
};
}
const okDb = {
query: async () => [],
execute: async () => ({ rowsAffected: 0 }),
};
describe("openapi-doc", () => {
it("served doc lists 3 /users/me routes with Zod-typed schemas", {
timeout: 30_000,
}, async () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "user-test", version: "0.1.0" },
});
const plugin = createUserPlugin({ applySchemaOnInit: false });
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("authService", fakeAuthService());
services.set("db", okDb);
await plugin.onInit?.(
createPluginContext<UserPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as UserPluginConfig,
services,
})
);
registry.applyTo(app, "/openapi.json");
const res = await app.request("/openapi.json");
const doc = (await res.json()) as {
paths: Record<string, Record<string, unknown>>;
};
const me = doc.paths?.["/users/me"];
expect(me).toBeDefined();
const methods = Object.keys(me as object).sort();
expect(methods).toEqual(["delete", "get", "patch"]);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { UpdateUserProfileBodySchema } from "../src/schemas/user-profile.js";
const KNOWN_FIELDS = ["displayName", "avatarUrl", "locale", "timezone"];
const valueArb = fc.oneof(
fc.string({ minLength: 1, maxLength: 16 }),
fc.integer(),
fc.boolean()
);
const keyArb = fc.string({ minLength: 1, maxLength: 16 });
describe("UpdateUserProfileBodySchema strict-mode fast-check", () => {
it("rejects every payload that contains an unknown key", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.dictionary(keyArb, valueArb, {
minKeys: 1,
maxKeys: 6,
}),
(input) => {
const keys = Object.keys(input);
const hasUnknown = keys.some(
(k) => !KNOWN_FIELDS.includes(k)
);
const result = UpdateUserProfileBodySchema.safeParse(input);
if (hasUnknown) {
return result.success === false;
}
// No unknown keys — accept depends on per-field value
// validation; this branch verifies that strict-mode
// alone does not reject a known-key payload.
return true;
}
),
{ numRuns: 200 }
);
});
it("accepts the canonical empty patch", { timeout: 5000 }, () => {
const r = UpdateUserProfileBodySchema.safeParse({});
expect(r.success).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/api-plugin-user.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. Node 18+
* + Bun expose `crypto.subtle` / `crypto.getRandomValues` globally so no
* crypto polyfill is required.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}