test: enforce per-directory coverage thresholds

entities + use-cases + controllers must hit 100% (95% branches).
Project-wide baseline remains 80/75/80/80. Tightening these directories
reflects the architectural intent: these are the pure-logic layers and
should be exhaustively tested.

Added @vitest/coverage-v8@^3 to core-typescript, auth, blog,
marketing-pages, and navigation devDependencies.

Excluded from coverage (legitimately untestable):
- src/di/bind-production.ts — InversifyJS startup bootstrap
- src/application/repositories/** — pure TypeScript interfaces
- src/application/services/** — pure TypeScript interfaces (auth)
- src/integrations/cms/** — declarative Payload CMS config
- src/entities/cookie.ts — pure type aliases (auth)
- src/ui/** — React Query helpers, integration-tested in apps

Tests added (6 new files / 1 extended):
- auth/src/entities/errors.test.ts — UnauthenticatedError, UnauthorizedError,
  AuthenticationError, InputParseError constructors
- blog/src/entities/errors.test.ts — ArticleNotFoundError (default + custom
  message), InputParseError
- marketing-pages/src/entities/errors.test.ts — PageNotFoundError, InputParseError
- marketing-pages/src/entities/site-settings.test.ts — siteSettingsSchema
  (valid, no description, empty name rejection)
- navigation/src/entities/header.test.ts — headerItemSchema and headerSchema
  validation paths
- navigation/src/.../payload-header.repository.test.ts — logo relation-object,
  scalar-id, null, and null-item-field branches for full branch coverage

Spec: §6.9
This commit is contained in:
2026-05-05 19:51:34 +02:00
parent 98c25f3207
commit 9415eb1c5a
16 changed files with 510 additions and 3 deletions

View File

@@ -28,6 +28,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
AuthenticationError,
UnauthenticatedError,
UnauthorizedError,
InputParseError,
} from "./errors";
describe("AuthenticationError", () => {
it("is an instance of Error with the given message", () => {
const err = new AuthenticationError("bad credentials");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("bad credentials");
});
});
describe("UnauthenticatedError", () => {
it("is an instance of Error with the given message", () => {
const err = new UnauthenticatedError("not logged in");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("not logged in");
});
});
describe("UnauthorizedError", () => {
it("is an instance of Error with the given message", () => {
const err = new UnauthorizedError("forbidden");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("forbidden");
});
});
describe("InputParseError", () => {
it("is an instance of Error with the given message", () => {
const err = new InputParseError("invalid input");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("invalid input");
});
});

View File

@@ -3,6 +3,47 @@ import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
exclude: [
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
"src/di/bind-production.ts",
// Pure TypeScript interface files — not executable
"src/application/repositories/**",
"src/application/services/**",
// Payload CMS collection config — declarative data, tested via Payload integration
"src/integrations/cms/**",
// Pure type-alias file — no executable code
"src/entities/cookie.ts",
// React Query option builders — integration-tested in apps
"src/ui/**",
],
thresholds: {
"src/entities/**": {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
"src/application/use-cases/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
"src/interface-adapters/controllers/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},

View File

@@ -28,6 +28,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { ArticleNotFoundError, InputParseError } from "./errors";
describe("ArticleNotFoundError", () => {
it("uses the default message when none is given", () => {
const err = new ArticleNotFoundError();
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("Article not found");
});
it("uses a custom message when provided", () => {
const err = new ArticleNotFoundError("could not find article abc");
expect(err.message).toBe("could not find article abc");
});
});
describe("InputParseError", () => {
it("is an instance of Error with the given message", () => {
const err = new InputParseError("invalid input");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("invalid input");
});
});

View File

@@ -3,6 +3,44 @@ import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
exclude: [
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
"src/di/bind-production.ts",
// Pure TypeScript interface files — not executable
"src/application/repositories/**",
// Payload CMS collection config — declarative data, tested via Payload integration
"src/integrations/cms/**",
// React Query option builders — integration-tested in apps
"src/ui/**",
],
thresholds: {
"src/entities/**": {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
"src/application/use-cases/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
"src/interface-adapters/controllers/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},

View File

@@ -15,6 +15,7 @@
"test": "vitest run"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}
}

View File

@@ -28,6 +28,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { PageNotFoundError, InputParseError } from "./errors";
describe("PageNotFoundError", () => {
it("uses the default message when none is given", () => {
const err = new PageNotFoundError();
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("Page not found");
});
it("uses a custom message when provided", () => {
const err = new PageNotFoundError("could not find page /about");
expect(err.message).toBe("could not find page /about");
});
});
describe("InputParseError", () => {
it("is an instance of Error with the given message", () => {
const err = new InputParseError("invalid input");
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("invalid input");
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { siteSettingsSchema } from "./site-settings";
describe("siteSettingsSchema", () => {
it("accepts a valid site settings object", () => {
const result = siteSettingsSchema.parse({
siteName: "My Site",
siteDescription: "A great site",
});
expect(result.siteName).toBe("My Site");
expect(result.siteDescription).toBe("A great site");
});
it("accepts site settings without a description", () => {
const result = siteSettingsSchema.parse({ siteName: "My Site" });
expect(result.siteName).toBe("My Site");
expect(result.siteDescription).toBeUndefined();
});
it("rejects an empty siteName", () => {
expect(() => siteSettingsSchema.parse({ siteName: "" })).toThrow();
});
});

View File

@@ -3,6 +3,44 @@ import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
exclude: [
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
"src/di/bind-production.ts",
// Pure TypeScript interface files — not executable
"src/application/repositories/**",
// Payload CMS collection/global config — declarative data, tested via Payload integration
"src/integrations/cms/**",
// React Query option builders — integration-tested in apps
"src/ui/**",
],
thresholds: {
"src/entities/**": {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
"src/application/use-cases/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
"src/interface-adapters/controllers/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},

View File

@@ -28,6 +28,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { headerSchema, headerItemSchema } from "./header";
describe("headerItemSchema", () => {
it("accepts a valid nav item", () => {
const result = headerItemSchema.parse({
label: "Home",
href: "/",
});
expect(result.label).toBe("Home");
expect(result.external).toBe(false);
});
it("accepts an external nav item", () => {
const result = headerItemSchema.parse({
label: "Blog",
href: "https://blog.example.com",
external: true,
});
expect(result.external).toBe(true);
});
it("rejects an empty label", () => {
expect(() => headerItemSchema.parse({ label: "", href: "/" })).toThrow();
});
it("rejects a label over 64 chars", () => {
expect(() =>
headerItemSchema.parse({ label: "x".repeat(65), href: "/" }),
).toThrow();
});
it("rejects an empty href", () => {
expect(() =>
headerItemSchema.parse({ label: "Home", href: "" }),
).toThrow();
});
});
describe("headerSchema", () => {
it("accepts a valid header with items", () => {
const result = headerSchema.parse({
items: [{ label: "Home", href: "/" }],
});
expect(result.items).toHaveLength(1);
expect(result.logoId).toBeUndefined();
});
it("accepts a header with a logoId", () => {
const result = headerSchema.parse({
logoId: "logo-abc",
items: [],
});
expect(result.logoId).toBe("logo-abc");
});
it("accepts a header with no items", () => {
const result = headerSchema.parse({ items: [] });
expect(result.items).toHaveLength(0);
});
});

View File

@@ -1,4 +1,4 @@
import { describe, vi } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { PayloadHeaderRepository } from "@/infrastructure/repositories/payload-header.repository";
import { headerRepositoryContract, CONTRACT_HEADER_SEED } from "@/__contracts__/header-repository.contract";
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
@@ -7,10 +7,10 @@ import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
// In-memory Payload stub for header (Global)
// ---------------------------------------------------------------------------
function buildHeaderStub() {
function buildHeaderStub(overrides?: Record<string, unknown>) {
return {
findGlobal: vi.fn(async () => {
return { ...CONTRACT_HEADER_SEED };
return { ...CONTRACT_HEADER_SEED, ...overrides };
}),
};
}
@@ -32,4 +32,58 @@ describe("PayloadHeaderRepository", () => {
return new PayloadHeaderRepository(stubPayloadConfig);
});
});
// ---------------------------------------------------------------------------
// Logo field branch coverage
// ---------------------------------------------------------------------------
it("resolves logoId from a relation object ({ id })", async () => {
const stub = buildHeaderStub({ logo: { id: "abc-123" } });
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new PayloadHeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.logoId).toBe("abc-123");
});
it("resolves logoId from a scalar id (string)", async () => {
const stub = buildHeaderStub({ logo: "scalar-id" });
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new PayloadHeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.logoId).toBe("scalar-id");
});
it("resolves logoId as undefined when logo is null", async () => {
const stub = buildHeaderStub({ logo: null });
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new PayloadHeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.logoId).toBeUndefined();
});
it("maps null item fields to empty strings and false", async () => {
const stub = {
findGlobal: vi.fn(async () => ({
items: [{ label: null, href: null, external: null }],
})),
};
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new PayloadHeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.items[0]?.label).toBe("");
expect(header.items[0]?.href).toBe("");
expect(header.items[0]?.external).toBe(false);
});
});

View File

@@ -3,6 +3,44 @@ import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
exclude: [
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
"src/di/bind-production.ts",
// Pure TypeScript interface files — not executable
"src/application/repositories/**",
// Payload CMS global config — declarative data, tested via Payload integration
"src/integrations/cms/**",
// React Query option builders — integration-tested in apps
"src/ui/**",
],
thresholds: {
"src/entities/**": {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
"src/application/use-cases/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
"src/interface-adapters/controllers/**": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},