Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-otters-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"api": patch
---

Add a starter test suite for the API plugin: vitest config, an integration harness (`tests/setup.ts`) that boots the plugin runtime over HTTP with typed oRPC clients and injectable auth/org context, integration tests for public and authenticated routes, and PGlite-backed unit tests for `TenantsService`. The root `test:api` script now runs real tests instead of erroring with "no test files found".
117 changes: 117 additions & 0 deletions api/tests/integration/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { authedContext, getPluginClient, orgContext } from "../setup";

describe("API Plugin Integration Tests", () => {
describe("ping", () => {
it("returns healthy status", async () => {
const client = await getPluginClient();
const result = await client.ping();

expect(result).toEqual({
status: "ok",
timestamp: expect.any(String),
});
});
});

describe("authHealth", () => {
it("rejects unauthenticated requests", async () => {
const client = await getPluginClient();
await expect(client.authHealth()).rejects.toThrow("Authentication required");
});

it("returns status when authenticated", async () => {
const client = await getPluginClient(authedContext());
const result = await client.authHealth();

expect(result.status).toBe("ok");
expect(result.emailConfigured).toEqual(expect.any(Boolean));
expect(result.smsConfigured).toEqual(expect.any(Boolean));
});
});

describe("resolveTenant", () => {
it("returns null for an unknown account", async () => {
const client = await getPluginClient();
const result = await client.resolveTenant({ accountId: "nobody.near" });
expect(result).toBeNull();
});

it("resolves a tenant created by its owning organization", async () => {
const client = await getPluginClient(orgContext());

const created = await client.createTenant({
subdomain: "acme",
name: "Acme Corp",
accountId: "acme.example.near",
status: "active",
});
expect(created).toMatchObject({
subdomain: "acme",
name: "Acme Corp",
accountId: "acme.example.near",
orgId: "org-1",
status: "active",
});

const resolved = await client.resolveTenant({ accountId: "acme.example.near" });
expect(resolved?.id).toBe(created.id);
});

it("rejects invalid accountId format on create", async () => {
const client = await getPluginClient(orgContext());
await expect(
client.createTenant({
subdomain: "acme",
name: "Acme Corp",
accountId: "NOT-A-VALID-ACCOUNT",
}),
).rejects.toThrow("Invalid accountId format");
});
});

describe("tenantPreflight", () => {
it("reports availability for a fresh subdomain", async () => {
const client = await getPluginClient(authedContext());
const result = await client.tenantPreflight({
subdomain: "acmename",
parentAccount: "example.near",
});

expect(result.subdomain.available).toBe(true);
expect(result.subdomain.reserved).toBe(false);
expect(result.accountId.format).toBe("valid");
expect(result.accountId.available).toBe(true);
});

it("flags reserved subdomains", async () => {
const client = await getPluginClient(authedContext());
const result = await client.tenantPreflight({
subdomain: "admin",
parentAccount: "example.near",
});

expect(result.subdomain.reserved).toBe(true);
expect(result.subdomain.available).toBe(false);
});
});

describe("testError", () => {
it("maps error kinds to client-visible failures", async () => {
const client = await getPluginClient();

await expect(client.testError({ kind: "unauthorized" })).rejects.toThrow(
"test unauthorized error",
);
await expect(client.testError({ kind: "forbidden" })).rejects.toThrow("test forbidden error");
await expect(client.testError({ kind: "not_found" })).rejects.toThrow("test not found error");
await expect(client.testError({ kind: "conflict" })).rejects.toThrow("test conflict error");
await expect(client.testError({ kind: "bad_request" })).rejects.toThrow(
"test bad request error",
);
await expect(client.testError({ kind: "internal" as never })).rejects.toThrow(
"Internal server error",
);
});
});
});
119 changes: 119 additions & 0 deletions api/tests/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { createServer } from "node:http";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { ContractRouterClient } from "@orpc/contract";
import { RPCHandler } from "@orpc/server/node";
import { createPluginRuntime } from "every-plugin";
import type { contract } from "@/contract";
import Plugin from "@/index";
import pluginDevConfig from "../plugin.dev";

const TEST_PLUGIN_ID = pluginDevConfig.pluginId;
const TEST_CONFIG = pluginDevConfig.config;

const TEST_REGISTRY = {
[TEST_PLUGIN_ID]: {
module: Plugin,
description: "API integration test runtime",
},
} as const;

export const runtime = createPluginRuntime({
registry: TEST_REGISTRY,
secrets: {},
});

let server: ReturnType<typeof createServer> | null = null;
let baseUrl = "";
let port = 0;

export async function getPluginClient(context?: Record<string, unknown>) {
if (!server) {
const { router } = await runtime.usePlugin(TEST_PLUGIN_ID, TEST_CONFIG);
const rpcHandler = new RPCHandler(router);

// Find an available port
const testPort = 3000 + Math.floor(Math.random() * 1000);
port = testPort;
baseUrl = `http://localhost:${port}`;

server = createServer(async (req, res) => {
const url = new URL(req.url!, baseUrl);

if (url.pathname.startsWith("/rpc")) {
// Initialize empty context for each request to prevent closure capture
let requestContext = {};

// Allow overriding context via headers for flexibility
if (req.headers["x-test-context"]) {
requestContext = JSON.parse(req.headers["x-test-context"] as string);
}

const result = await rpcHandler.handle(req, res, {
prefix: "/rpc",
context: requestContext,
});
if (result.matched) return;
}

res.statusCode = 404;
res.end("Route not found");
});

await new Promise<void>((resolve, reject) => {
server?.listen(port, "127.0.0.1", () => resolve());
server?.on("error", reject);
});
}

const link = new RPCLink({
url: `${baseUrl}/rpc`,
fetch: globalThis.fetch,
headers: context
? {
"x-test-context": JSON.stringify(context),
}
: {},
});

const client: ContractRouterClient<typeof contract> = createORPCClient(link);
return client;
}

export function authedContext(userId = "user-1"): Record<string, unknown> {
return {
userId,
user: {
id: userId,
email: `${userId}@example.com`,
name: "Test User",
},
};
}

export function orgContext(
userId = "user-1",
activeOrganizationId = "org-1",
): Record<string, unknown> {
return {
...authedContext(userId),
organization: {
activeOrganizationId,
organization: {
id: activeOrganizationId,
slug: activeOrganizationId,
metadata: null,
},
},
};
}

export async function teardown() {
if (server) {
await new Promise<void>((resolve) => {
server?.close(() => resolve());
});
server = null;
}
await runtime.shutdown();
}
Loading
Loading