Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Testing a plugin

The three test layers, what each is good at, and the harness each one gives you.

Documents 0.13.1 Updated Edit this page Report a problem

On this page
LayerRunsGood for
UnitJest (server) or Vitest (admin), in processPure logic — validators, mappers, the domain/ layer, tool definitions
Server end-to-endThe real app, in process, against real Postgres, via supertestGuards, permissions, transactions, migrations, actual SQL
Admin end-to-endPlaywright against the admin dev server, /api/** mockedFlows, keyboard, accessibility
Example language
npx nx test @acme/reviews-server   # unit
npx nx e2e server-e2e                 # API against a Postgres testcontainer
npx nx e2e admin-e2e                  # Playwright; starts the dev server itself
npm test              # server (Jest) + admin (Vitest) unit tests
npm run e2e:server    # API against the _e2e database
npm run e2e:admin     # Playwright

Unit tests

Keep rules in domain/ — no Nest, no Drizzle, no React — and test them directly. A tool provider is also unit-testable, because its definitions are plain data:

// review-tool.provider.spec.ts
import { PERMISSIONS } from '@orthacms/identity-server';
import { ReviewToolProvider } from './review-tool.provider';

describe('ReviewToolProvider', () => {
    const tools = new ReviewToolProvider({} as never).tools();

    it('offers one read tool, on the copilot only', () => {
        expect(tools.map((t) => t.name)).toEqual(['review_list']);
        expect(tools[0]).toMatchObject({
            readOnly: true,
            effect: 'read',
            requires: [PERMISSIONS.CONTENT_READ],
            surfaces: ['copilot']
        });
    });

    it('registers only where a registry exists', () => {
        const registered: unknown[] = [];
        const provider = new ReviewToolProvider({} as never, {
            register: (p: unknown) => registered.push(p)
        } as never);
        provider.onModuleInit();
        expect(registered).toEqual([provider]);

        expect(() => new ReviewToolProvider({} as never).onModuleInit()).not.toThrow();
    });
});

Server end-to-end (monorepo)

The harness in apps/server-e2e/src/support/ boots the real app — same plugins, prefix, validation pipe and API docs as production — against one Postgres testcontainer, migrated once by global setup.

Wire your plugin in first:

  1. Add ReviewsPlugin() to buildTestPlugins in support/plugins.ts. Global setup migrates from that list.
  2. Add reviews to the TRUNCATE list in resetDb (support/seed.ts). It truncates an explicit list, and a plain-uuid table is reached by no cascade.
// apps/server-e2e/src/server/reviews/reviews.spec.ts
import request from 'supertest';
import { closeTestApp, createTestApp, type TestApp } from '../../support/test-app';
import {
    resetDb,
    seedActiveUser,
    seedAllContentGrants,
    seedMembership,
    seedWorkspace
} from '../../support/seed';

const PASSWORD = 'SecurePass123!';

let harness: TestApp;
let workspaceId: string;

beforeAll(async () => {
    harness = await createTestApp();
});

afterAll(async () => {
    await closeTestApp(harness);
});

beforeEach(async () => {
    await resetDb();
    workspaceId = (await seedWorkspace({ name: 'Reviews', slug: 'reviews' })).id;
    await seedAllContentGrants(workspaceId);
});

/** A signed-in agent for a fresh user of `role`, inside the workspace. */
async function signIn(role: 'contributor' | 'viewer') {
    const email = `${role}@example.com`;
    const user = await seedActiveUser(harness.app, { email, password: PASSWORD, role });
    await seedMembership(user.id, workspaceId);
    const agent = request.agent(harness.server);
    await agent.post('/api/auth/login').send({ email, password: PASSWORD }).expect(201);
    agent.set('X-Workspace-Id', workspaceId);
    return agent;
}

describe('POST /api/reviews', () => {
    const entryId = '00000000-0000-4000-8000-000000000001';

    it('creates a review for a contributor', async () => {
        const agent = await signIn('contributor');
        const res = await agent
            .post('/api/reviews')
            .send({ entryId, body: 'Tighten the intro.' })
            .expect(201);
        expect(res.body).toMatchObject({ workspaceId, entryId, resolvedAt: null });
    });

    it('refuses a viewer, who lacks content:update', async () => {
        const agent = await signIn('viewer');
        await agent.post('/api/reviews').send({ entryId, body: 'x' }).expect(403);
    });

    it('needs the workspace header', async () => {
        const agent = await signIn('contributor');
        agent.set('X-Workspace-Id', '');
        await agent.post('/api/reviews').send({ entryId, body: 'x' }).expect(400);
    });
});

What the container buys you:

  • Guards run. A route that forgot @RequirePermissions fails here and nowhere else.
  • Migrations run. A schema change that does not apply fails the suite.
  • Constraints are real. A count-then-write race or a missing unique index shows up.

Useful helpers:

HelperFromDoes
createTestApp(overrides?) / closeTestApp(h)support/test-appBoot / stop. overrides changes config: rateLimit, allowedOrigins, mail, webhooks, session…
resetDb()support/seedTruncate the mutable tables (incl. outbox_events); keeps system roles
seedActiveUser(app, { email, password, role })support/seedA user whose password is hashed by the app’s own HashingService
seedUserWithPermissions, seedUserWithEmptyRolesupport/seedProve a route needs a specific key
seedWorkspace({ name, slug, isShared? }), seedMembership(userId, wsId)support/seedWorkspace and membership rows
seedAllContentGrants(wsId), seedSharedContentGrants(…)support/seedContent-type grants, own and shared
drainOutbox(app), getOutboxRows(aggregateId)support/outboxDeliver pending events now; assert what was emitted

Asserting an event was emitted:

import { getOutboxRows } from '../../support/outbox';

const res = await agent.post('/api/reviews').send({ entryId, body: 'Ok' }).expect(201);
const rows = await getOutboxRows(res.body.id);
expect(rows.map((r) => r.kind)).toEqual(['review.created']);

Server end-to-end (generated app)

An app from create-orthacms-app ships a smaller harness in apps/server-e2e/. It boots buildPlugins(config) — so your plugin is already in it — against an _e2e copy of your database (or E2E_DATABASE_URL), and signs in as the root admin from .env.

// apps/server-e2e/src/reviews.spec.ts
import request from 'supertest';
import { ALLOWED_ORIGIN, closeTestApp, createTestApp, type TestApp } from './support/test-app';

let harness: TestApp;

beforeAll(async () => {
    harness = await createTestApp();
});

afterAll(async () => {
    await closeTestApp(harness);
});

it('refuses an anonymous read', async () => {
    await request(harness.server).get('/api/reviews').expect(401);
});

it('asks a signed-in admin which workspace', async () => {
    const agent = request.agent(harness.server);
    await agent
        .post('/api/auth/login')
        .set('Origin', ALLOWED_ORIGIN)
        .send({
            email: process.env['ORTHACMS_ROOT_ADMIN_EMAIL'],
            password: process.env['ORTHACMS_ROOT_ADMIN_PASSWORD']
        })
        .expect(201);

    // Authenticated and permitted — WorkspaceGuard still wants X-Workspace-Id
    await agent.get('/api/reviews?entryId=00000000-0000-4000-8000-000000000001').expect(400);
});

resetDatabase(url) in support/db.ts truncates every table except the migration history.

Admin end-to-end

Playwright against the admin’s Vite dev server, with /api/** intercepted — the “seed” is a route mock. No server, no Postgres, no Docker. Register your admin plugin in apps/admin/src/plugins.ts so the dev server serves it.

// apps/admin-e2e/src/reviews/reviews.spec.ts
import { expect, test } from '../support/fixtures';
import { mockSignedIn } from '../support/api/auth';
import { mockWorkspaces } from '../support/api/workspaces';
import { expectNoA11yViolations } from '../support/a11y';

test.beforeEach(async ({ page }) => {
    await mockSignedIn(page); // an admin holding every permission
    await mockWorkspaces(page); // seeds 'ws_marketing' and others
    await page.route('**/api/reviews*', (route) =>
        route.fulfill({
            status: 200,
            contentType: 'application/json',
            body: JSON.stringify([{ id: 'r1', body: 'Tighten the intro.', resolvedAt: null }])
        })
    );
});

test('the Reviews page lists reviews and passes axe', async ({ page, makeAxe }) => {
    await page.goto('/workspaces/ws_marketing/reviews');
    await expect(page.getByText('Tighten the intro.')).toBeVisible();
    await expectNoA11yViolations(makeAxe());
});

test('a user without content:read gets no nav entry', async ({ page }) => {
    await mockSignedIn(page, { permissions: [] });
    await page.goto('/workspaces/ws_marketing');
    await expect(page.getByRole('link', { name: 'Reviews' })).toHaveCount(0);
});
  • Import test from support/fixtures, not @playwright/test — it carries the page objects and makeAxe.
  • Axe runs WCAG 2.1 A and AA plus best practices, in both themes.
  • Add a keyboard spec for any new page; the existing suites walk every screen without a mouse.

Where the conventions live

  • Skills — .agents/skills/: server-plugin, admin-plugin, server-e2e, admin-e2e, accessibility, shadcn. Read the one matching what you build.
  • Package dossiers — docs/artifacts/<package>.md: data model, HTTP API, invariants and a testing checklist per package. A snapshot — check against the code before acting on one.

No load tests, no contract tests

There is no performance suite and nothing pins the public API’s response shapes against a consumer. If you depend on a field, an end-to-end test in your codebase is what protects you.