Files
suaveplan/packages/api-stack/api/api-tenant/tests/integration/tenant-middleware-roundtrip.test.ts
2026-05-27 23:29:53 +01:00

152 lines
4.8 KiB
TypeScript

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);
});
});