Initial Commit
This commit is contained in:
179
packages/api-stack/api/api-tenant/tests/dep-graph.test.ts
Normal file
179
packages/api-stack/api/api-tenant/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_DIR = resolve(__dirname, "..");
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
exports?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ALLOWED_DEPS = new Set([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-plugin",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) {
|
||||
if (name === "node_modules" || name === "dist") continue;
|
||||
walk(p, out);
|
||||
} else {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("dependencies match the server allowlist", { timeout: 5000 }, () => {
|
||||
const deps = Object.keys(pkg.dependencies ?? {});
|
||||
for (const d of deps) {
|
||||
expect(ALLOWED_DEPS.has(d)).toBe(true);
|
||||
}
|
||||
// Every allowed dep is present (no skip-by-omission).
|
||||
for (const expected of [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/api-plugin",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
]) {
|
||||
expect(deps).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("subpath exports cover every feature", { timeout: 5000 }, () => {
|
||||
const exports = pkg.exports ?? {};
|
||||
const expected = [
|
||||
".",
|
||||
"./error-codes",
|
||||
"./types/tenant",
|
||||
"./resolver",
|
||||
"./cache",
|
||||
"./context",
|
||||
"./middleware",
|
||||
"./rate-limit",
|
||||
"./lifecycle",
|
||||
"./storage",
|
||||
];
|
||||
for (const e of expected) {
|
||||
expect(Object.keys(exports)).toContain(e);
|
||||
}
|
||||
});
|
||||
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const srcDir = join(PKG_DIR, "src");
|
||||
const all = walk(srcDir);
|
||||
const missing: string[] = [];
|
||||
for (const file of all) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (file.endsWith(".test.ts")) continue;
|
||||
if (file.endsWith(".spec.ts")) continue;
|
||||
if (file.endsWith(".types.ts")) continue;
|
||||
const base = file.replace(/\.ts$/, "");
|
||||
const fileName = base.split("/").pop();
|
||||
if (fileName === "index") continue;
|
||||
// The root index.ts is covered by the package README.
|
||||
if (file === join(srcDir, "index.ts")) continue;
|
||||
const md = `${base}.md`;
|
||||
try {
|
||||
statSync(md);
|
||||
} catch {
|
||||
missing.push(md.replace(`${PKG_DIR}/`, ""));
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("no biome suppressions in src/", { timeout: 5000 }, () => {
|
||||
const all = walk(join(PKG_DIR, "src"));
|
||||
const hits: string[] = [];
|
||||
for (const f of all) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
const txt = readFileSync(f, "utf8");
|
||||
if (txt.includes("biome-ignore")) hits.push(f);
|
||||
if (txt.includes("eslint-disable")) hits.push(f);
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("no banned patterns in src/", { timeout: 5000 }, () => {
|
||||
const banned = [
|
||||
"TODO",
|
||||
"FIXME",
|
||||
"ts-ignore",
|
||||
"ts-expect-error",
|
||||
"@deprecated",
|
||||
"console.log",
|
||||
];
|
||||
const all = walk(join(PKG_DIR, "src"));
|
||||
const hits: { file: string; pattern: string }[] = [];
|
||||
for (const f of all) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
const txt = readFileSync(f, "utf8");
|
||||
for (const p of banned) {
|
||||
if (txt.includes(p)) hits.push({ file: f, pattern: p });
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
|
||||
const featureBarrels = readdirSync(join(PKG_DIR, "src"))
|
||||
.filter((entry) => {
|
||||
const p = join(PKG_DIR, "src", entry);
|
||||
try {
|
||||
return statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((d) => join(PKG_DIR, "src", d, "index.ts"));
|
||||
const hits: string[] = [];
|
||||
for (const b of featureBarrels) {
|
||||
try {
|
||||
const txt = readFileSync(b, "utf8");
|
||||
if (/^export\s+\*/m.test(txt)) hits.push(b);
|
||||
} catch {
|
||||
/* directory may have no index.ts */
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Guard against accidental flat-error-codes file showing up.
|
||||
describe("layout invariants", () => {
|
||||
it("error-codes lives at src/error-codes/error-codes.ts (directory form)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inner = join(PKG_DIR, "src", "error-codes", "error-codes.ts");
|
||||
statSync(inner);
|
||||
const dir = dirname(inner);
|
||||
// No flat src/error-codes.ts collision.
|
||||
const flat = join(PKG_DIR, "src", "error-codes.ts");
|
||||
let flatExists = true;
|
||||
try {
|
||||
statSync(flat);
|
||||
} catch {
|
||||
flatExists = false;
|
||||
}
|
||||
expect(flatExists).toBe(false);
|
||||
expect(dir.endsWith("/error-codes")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
chainResolvers,
|
||||
createTenantMiddleware,
|
||||
headerResolver,
|
||||
MemoryRateLimitStore,
|
||||
subdomainResolver,
|
||||
TENANT_CODES,
|
||||
type Tenant,
|
||||
TenantCache,
|
||||
TenantError,
|
||||
tenantRateLimit,
|
||||
} from "../../src/index.js";
|
||||
|
||||
function makeTenant(id: string, over: Partial<Tenant> = {}): Tenant {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
slug: id,
|
||||
status: "active",
|
||||
settings: {},
|
||||
enabledPlugins: [],
|
||||
domains: [],
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("integration — tenant middleware roundtrip (real Hono + cache + store)", () => {
|
||||
it("resolves via chain, caches positive lookup, enforces rate limit", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const db = new Map<string, Tenant>([
|
||||
["acme", makeTenant("acme")],
|
||||
["beta", makeTenant("beta")],
|
||||
]);
|
||||
let dbCalls = 0;
|
||||
const lookup = async (k: string): Promise<Tenant | null> => {
|
||||
dbCalls += 1;
|
||||
return db.get(k) ?? null;
|
||||
};
|
||||
|
||||
const resolver = TenantCache.wrap(
|
||||
chainResolvers(
|
||||
headerResolver("x-tenant-id", lookup),
|
||||
subdomainResolver("app.test", lookup)
|
||||
),
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
|
||||
const store = new MemoryRateLimitStore(100);
|
||||
const app = new Hono();
|
||||
app.use("*", createTenantMiddleware({ resolver }));
|
||||
app.use(
|
||||
"*",
|
||||
tenantRateLimit({
|
||||
maxRequests: 2,
|
||||
windowMs: 60_000,
|
||||
store,
|
||||
keyPrefix: "rl",
|
||||
})
|
||||
);
|
||||
app.get("/me", (c) => c.json({ id: c.get("tenant").id }));
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof TenantError) {
|
||||
return c.json({ code: err.code }, 404);
|
||||
}
|
||||
return c.text("boom", 500);
|
||||
});
|
||||
|
||||
// 1. First hit — cold cache, db touched.
|
||||
const r1 = await app.request("/me", {
|
||||
headers: { "x-tenant-id": "acme" },
|
||||
});
|
||||
expect(r1.status).toBe(200);
|
||||
expect(dbCalls).toBe(1);
|
||||
|
||||
// 2. Second hit — cache hit, db untouched.
|
||||
const r2 = await app.request("/me", {
|
||||
headers: { "x-tenant-id": "acme" },
|
||||
});
|
||||
expect(r2.status).toBe(200);
|
||||
expect(dbCalls).toBe(1);
|
||||
|
||||
// 3. Third hit — rate-limit exceeded.
|
||||
const r3 = await app.request("/me", {
|
||||
headers: { "x-tenant-id": "acme" },
|
||||
});
|
||||
expect(r3.status).toBe(429);
|
||||
const body = (await r3.json()) as { code: string };
|
||||
expect(body.code).toBe(TENANT_CODES.RATE_LIMITED);
|
||||
expect(r3.headers.get("Retry-After")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("subdomain fallback resolves when header missing", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const db = new Map<string, Tenant>([["sub", makeTenant("sub")]]);
|
||||
const resolver = TenantCache.wrap(
|
||||
chainResolvers(
|
||||
headerResolver("x-tenant-id", async (k) => db.get(k) ?? null),
|
||||
subdomainResolver("app.test", async (k) => db.get(k) ?? null)
|
||||
),
|
||||
{ ttlMs: 60_000, negativeTtlMs: 1000 }
|
||||
);
|
||||
const app = new Hono();
|
||||
app.use("*", createTenantMiddleware({ resolver }));
|
||||
app.get("/", (c) => c.text(c.get("tenant").id));
|
||||
app.onError((err, c) =>
|
||||
c.json(
|
||||
{
|
||||
code: err instanceof TenantError ? err.code : "x",
|
||||
message: err.message,
|
||||
},
|
||||
500
|
||||
)
|
||||
);
|
||||
const res = await app.request("http://sub.app.test/", {
|
||||
headers: { host: "sub.app.test" },
|
||||
});
|
||||
expect(await res.text()).toBe("sub");
|
||||
});
|
||||
|
||||
it("unknown tenant in chain → 404 via onError mapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const resolver = TenantCache.wrap(
|
||||
chainResolvers(
|
||||
headerResolver("x-tenant-id", async () => null),
|
||||
subdomainResolver("app.test", async () => null)
|
||||
),
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
const app = new Hono();
|
||||
app.use("*", createTenantMiddleware({ resolver }));
|
||||
app.get("/", (c) => c.text(c.get("tenant").id));
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof TenantError)
|
||||
return c.json({ code: err.code }, 404);
|
||||
return c.text("", 500);
|
||||
});
|
||||
const res = await app.request("http://unknown.app.test/", {
|
||||
headers: { host: "unknown.app.test" },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { code: string };
|
||||
expect(body.code).toBe(TENANT_CODES.NOT_FOUND);
|
||||
});
|
||||
});
|
||||
66
packages/api-stack/api/api-tenant/tests/property.test.ts
Normal file
66
packages/api-stack/api/api-tenant/tests/property.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { Hono } from "hono";
|
||||
import { headerResolver, type Tenant, TenantCache } from "../src/index.js";
|
||||
|
||||
function makeTenant(id: string): Tenant {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
slug: id,
|
||||
status: "active",
|
||||
settings: {},
|
||||
enabledPlugins: [],
|
||||
domains: [],
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("property — cache TTL boundary invariant", () => {
|
||||
it("entry expires strictly after ttlMs boundary (fast-check 200 runs)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 10, max: 10_000 }),
|
||||
fc.integer({ min: 0, max: 100_000 }),
|
||||
async (ttlMs, baseNow) => {
|
||||
let calls = 0;
|
||||
const inner = headerResolver("x-tenant-id", async (id) => {
|
||||
calls += 1;
|
||||
return makeTenant(id);
|
||||
});
|
||||
let now = baseNow;
|
||||
const cached = TenantCache.wrap(inner, {
|
||||
ttlMs,
|
||||
clock: () => now,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.get("/", async (c) => {
|
||||
await cached.resolve(c);
|
||||
return c.text("ok");
|
||||
});
|
||||
const hit = (): Promise<unknown> =>
|
||||
app.request("/", {
|
||||
headers: { "x-tenant-id": "k", host: "x" },
|
||||
});
|
||||
|
||||
await hit(); // populate
|
||||
expect(calls).toBe(1);
|
||||
|
||||
// Strictly inside TTL → cached.
|
||||
now = baseNow + ttlMs - 1;
|
||||
await hit();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
// At the boundary (expiresAt > now is false when equal) → re-lookup.
|
||||
now = baseNow + ttlMs;
|
||||
await hit();
|
||||
expect(calls).toBe(2);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
39
packages/api-stack/api/api-tenant/tests/setup/polyfills.ts
Normal file
39
packages/api-stack/api/api-tenant/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/api-tenant.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any
|
||||
* source module is imported, so the meter resolved at module load
|
||||
* is the real provider's meter — not the noop fallback. Tests that
|
||||
* inspect emitted metrics can read them out of
|
||||
* `testInMemoryMetricExporter`.
|
||||
*
|
||||
* Node ≥18 already exposes `crypto.subtle` and `crypto.getRandomValues`
|
||||
* globally, so no crypto polyfill is required. Server-tier: `node:async_hooks`
|
||||
* is allowed (used by the opt-in `withTenantStorage` builder).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user