refactor: strip Phase/Plan/R-number references from source comments
This commit is contained in:
@@ -3,89 +3,86 @@ import { defineContractSuite } from "@repo/core-testing/contract";
|
||||
import type { IUsersRepository } from "../application/repositories/users.repository.interface.js";
|
||||
import { userFactory } from "../__factories__/user.factory.js";
|
||||
|
||||
export const usersRepositoryContract =
|
||||
defineContractSuite<IUsersRepository>(
|
||||
"IUsersRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IUsersRepository;
|
||||
export const usersRepositoryContract = defineContractSuite<IUsersRepository>(
|
||||
"IUsersRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IUsersRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
userFactory.reset();
|
||||
repo = await buildSubject();
|
||||
beforeEach(async () => {
|
||||
userFactory.reset();
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
// --- getUser ---
|
||||
|
||||
it("createUser then getUser returns it by id", async () => {
|
||||
const seed = userFactory.build();
|
||||
await repo.createUser(seed);
|
||||
const result = await repo.getUser(seed.id);
|
||||
expect(result?.id).toBe(seed.id);
|
||||
expect(result?.username).toBe(seed.username);
|
||||
});
|
||||
|
||||
it("getUser returns undefined for missing id", async () => {
|
||||
expect(await repo.getUser("does-not-exist")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getUserByUsername ---
|
||||
|
||||
it("createUser then getUserByUsername returns it by username", async () => {
|
||||
const seed = userFactory.build({ username: "alice" });
|
||||
await repo.createUser(seed);
|
||||
const result = await repo.getUserByUsername("alice");
|
||||
expect(result?.id).toBe(seed.id);
|
||||
expect(result?.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("getUserByUsername returns undefined for missing username", async () => {
|
||||
expect(await repo.getUserByUsername("no-such-user")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- createUser ---
|
||||
|
||||
it("createUser returns the created user", async () => {
|
||||
const seed = userFactory.build({ username: "carol" });
|
||||
const created = await repo.createUser(seed);
|
||||
expect(created.id).toBe(seed.id);
|
||||
expect(created.username).toBe("carol");
|
||||
});
|
||||
|
||||
describe("span emission", () => {
|
||||
it("getUser emits users.getUser span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getUser("nonexistent");
|
||||
const span = tracer.findSpan("users.getUser");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
|
||||
// --- getUser ---
|
||||
it("getUserByUsername emits users.getUserByUsername span", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getUserByUsername("alice");
|
||||
const span = tracer.findSpan("users.getUserByUsername");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
});
|
||||
|
||||
it("createUser then getUser returns it by id", async () => {
|
||||
const seed = userFactory.build();
|
||||
it("createUser emits users.createUser span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
const seed = userFactory.build({ username: "span-test-user" });
|
||||
await repo.createUser(seed);
|
||||
const result = await repo.getUser(seed.id);
|
||||
expect(result?.id).toBe(seed.id);
|
||||
expect(result?.username).toBe(seed.username);
|
||||
const span = tracer.findSpan("users.createUser");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe(seed.id);
|
||||
});
|
||||
|
||||
it("getUser returns undefined for missing id", async () => {
|
||||
expect(await repo.getUser("does-not-exist")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getUserByUsername ---
|
||||
|
||||
it("createUser then getUserByUsername returns it by username", async () => {
|
||||
const seed = userFactory.build({ username: "alice" });
|
||||
await repo.createUser(seed);
|
||||
const result = await repo.getUserByUsername("alice");
|
||||
expect(result?.id).toBe(seed.id);
|
||||
expect(result?.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("getUserByUsername returns undefined for missing username", async () => {
|
||||
expect(
|
||||
await repo.getUserByUsername("no-such-user"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- createUser ---
|
||||
|
||||
it("createUser returns the created user", async () => {
|
||||
const seed = userFactory.build({ username: "carol" });
|
||||
const created = await repo.createUser(seed);
|
||||
expect(created.id).toBe(seed.id);
|
||||
expect(created.username).toBe("carol");
|
||||
});
|
||||
|
||||
describe("span emission (R50)", () => {
|
||||
it("getUser emits users.getUser span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getUser("nonexistent");
|
||||
const span = tracer.findSpan("users.getUser");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
|
||||
it("getUserByUsername emits users.getUserByUsername span", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getUserByUsername("alice");
|
||||
const span = tracer.findSpan("users.getUserByUsername");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
});
|
||||
|
||||
it("createUser emits users.createUser span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
const seed = userFactory.build({ username: "span-test-user" });
|
||||
await repo.createUser(seed);
|
||||
const span = tracer.findSpan("users.createUser");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe(seed.id);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { signInUseCase, signInOutputSchema } from "@/application/use-cases/sign-in.use-case";
|
||||
import {
|
||||
signInUseCase,
|
||||
signInOutputSchema,
|
||||
} from "@/application/use-cases/sign-in.use-case";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
@@ -18,7 +21,10 @@ describe("signInUseCase", () => {
|
||||
await users.createUser(seedUser);
|
||||
|
||||
const useCase = signInUseCase(users, auth);
|
||||
const result = await useCase({ username: "alice", password: "testpassword" });
|
||||
const result = await useCase({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
});
|
||||
|
||||
expect(result.session.userId).toBe(seedUser.id);
|
||||
expect(result.cookie.name).toBe("session");
|
||||
@@ -38,7 +44,10 @@ describe("signInUseCase", () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
await users.createUser(
|
||||
userFactory.build({ username: "alice", passwordHash: "hashed_correctpassword" }),
|
||||
userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_correctpassword",
|
||||
}),
|
||||
);
|
||||
|
||||
const useCase = signInUseCase(users, auth);
|
||||
@@ -48,7 +57,7 @@ describe("signInUseCase", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("signInUseCase output validation (R25)", () => {
|
||||
describe("signInUseCase output validation", () => {
|
||||
it("throws when authenticationService returns a malformed session", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const seed = userFactory.build({ username: "alice" });
|
||||
@@ -61,7 +70,9 @@ describe("signInUseCase output validation (R25)", () => {
|
||||
} as unknown as IAuthenticationService;
|
||||
|
||||
const useCase = signInUseCase(users, auth);
|
||||
await expect(useCase({ username: "alice", password: "x" })).rejects.toBeInstanceOf(ZodError);
|
||||
await expect(
|
||||
useCase({ username: "alice", password: "x" }),
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors the success shape", () => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
|
||||
import { signUpUseCase, signUpOutputSchema } from "@/application/use-cases/sign-up.use-case";
|
||||
import {
|
||||
signUpUseCase,
|
||||
signUpOutputSchema,
|
||||
} from "@/application/use-cases/sign-up.use-case";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
@@ -33,7 +36,11 @@ describe("signUpUseCase", () => {
|
||||
|
||||
const useCase = signUpUseCase(users, auth, bus);
|
||||
await expect(
|
||||
useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }),
|
||||
useCase({
|
||||
username: "alice",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
@@ -83,13 +90,17 @@ describe("signUpUseCase", () => {
|
||||
|
||||
const useCase = signUpUseCase(users, auth, bus);
|
||||
await expect(
|
||||
useCase({ username: "eve", password: "secret_password", confirmPassword: "secret_password" }),
|
||||
useCase({
|
||||
username: "eve",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
expect(bus.published).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signUpUseCase output validation (R25)", () => {
|
||||
describe("signUpUseCase output validation", () => {
|
||||
it("throws when authenticationService returns a malformed session", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = {
|
||||
@@ -103,7 +114,11 @@ describe("signUpUseCase output validation (R25)", () => {
|
||||
const bus = new RecordingEventBus();
|
||||
const useCase = signUpUseCase(users, auth, bus);
|
||||
await expect(
|
||||
useCase({ username: "carol", password: "secret_password", confirmPassword: "secret_password" }),
|
||||
useCase({
|
||||
username: "carol",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
|
||||
@@ -39,12 +39,16 @@ export const signUpUseCase =
|
||||
bus: EventBusProtocol | undefined,
|
||||
) =>
|
||||
async (input: SignUpInput): Promise<SignUpOutput> => {
|
||||
const existingUser = await usersRepository.getUserByUsername(input.username);
|
||||
const existingUser = await usersRepository.getUserByUsername(
|
||||
input.username,
|
||||
);
|
||||
if (existingUser) {
|
||||
throw new AuthenticationError("Username taken");
|
||||
}
|
||||
|
||||
const passwordHash = await authenticationService.hashPassword(input.password);
|
||||
const passwordHash = await authenticationService.hashPassword(
|
||||
input.password,
|
||||
);
|
||||
const userId = authenticationService.generateUserId();
|
||||
|
||||
const newUser = await usersRepository.createUser({
|
||||
@@ -53,11 +57,12 @@ export const signUpUseCase =
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const { cookie, session } = await authenticationService.createSession(newUser);
|
||||
const { cookie, session } =
|
||||
await authenticationService.createSession(newUser);
|
||||
|
||||
// Auth is username-based — synthesize a deterministic email so the event
|
||||
// payload validates against userSignedUpEventSchema.email().
|
||||
// bus is optional: absent when core-events is not wired (Phase 3+).
|
||||
// bus is optional: absent when core-events is not wired.
|
||||
if (bus) {
|
||||
await bus.publish(userSignedUpEvent, {
|
||||
userId: newUser.id,
|
||||
|
||||
@@ -44,8 +44,12 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
authContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
authContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
authContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
authContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
@@ -61,7 +65,9 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
||||
.toConstantValue(repo);
|
||||
|
||||
// Need auth service from container for use cases
|
||||
const authService = authContainer.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService);
|
||||
const authService = authContainer.get<IAuthenticationService>(
|
||||
AUTH_SYMBOLS.IAuthenticationService,
|
||||
);
|
||||
|
||||
// Wrap use cases + controllers identically to bind-production
|
||||
const wrappedSignIn = withSpan(
|
||||
@@ -102,9 +108,15 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
||||
]) {
|
||||
if (authContainer.isBound(sym)) authContainer.unbind(sym);
|
||||
}
|
||||
authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn);
|
||||
authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp);
|
||||
authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignInUseCase)
|
||||
.toConstantValue(wrappedSignIn);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignUpUseCase)
|
||||
.toConstantValue(wrappedSignUp);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignOutUseCase)
|
||||
.toConstantValue(wrappedSignOut);
|
||||
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignInController)
|
||||
@@ -145,8 +157,7 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -33,7 +33,8 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
||||
ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
@@ -42,15 +43,21 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
|
||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
authContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
authContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
authContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
authContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
authContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
// Real repositories
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
}
|
||||
const repo = new UsersRepository(config, tracer, logger);
|
||||
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(repo);
|
||||
authContainer
|
||||
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
||||
.toConstantValue(repo);
|
||||
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
||||
@@ -100,9 +107,15 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
|
||||
]) {
|
||||
if (authContainer.isBound(sym)) authContainer.unbind(sym);
|
||||
}
|
||||
authContainer.bind(AUTH_SYMBOLS.ISignInUseCase).toConstantValue(wrappedSignIn);
|
||||
authContainer.bind(AUTH_SYMBOLS.ISignUpUseCase).toConstantValue(wrappedSignUp);
|
||||
authContainer.bind(AUTH_SYMBOLS.ISignOutUseCase).toConstantValue(wrappedSignOut);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignInUseCase)
|
||||
.toConstantValue(wrappedSignIn);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignUpUseCase)
|
||||
.toConstantValue(wrappedSignUp);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignOutUseCase)
|
||||
.toConstantValue(wrappedSignOut);
|
||||
|
||||
// Controllers — wrapped with span at bind time
|
||||
for (const sym of [
|
||||
@@ -151,8 +164,7 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
export { SESSION_COOKIE } from "./config";
|
||||
|
||||
// Use case schemas + types (Plan 9 R18)
|
||||
// Use case schemas + types
|
||||
export {
|
||||
signInInputSchema,
|
||||
signInOutputSchema,
|
||||
@@ -44,7 +44,6 @@ export {
|
||||
} from "./events/user-signed-up.event";
|
||||
// <gen:realtime-channels>
|
||||
|
||||
// Feature conformance manifest (added in conformance milestone i, exposed
|
||||
// here in milestone ii so the boot-time assertion and future tooling can
|
||||
// read the contract from the package boundary).
|
||||
// Feature conformance manifest — declares this feature's use cases, audits,
|
||||
// publishes, and consumes. Read by the boot-time assertion + ESLint rules.
|
||||
export { authManifest, type AuthManifest } from "./feature.manifest";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
|
||||
// Mock repo also wraps in spans (R42); easier to assert without booting Payload.
|
||||
describe("MockUsersRepository emits spans (R42)", () => {
|
||||
// Mock repo also wraps in spans; easier to assert without booting Payload.
|
||||
describe("MockUsersRepository emits spans", () => {
|
||||
it("getUser emits one span with op='repository'", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
@@ -26,13 +29,19 @@ describe("MockUsersRepository emits spans (R42)", () => {
|
||||
);
|
||||
await repo.getUserByUsername("alice");
|
||||
expect(tracer.findSpan("users.getUserByUsername")).toBeDefined();
|
||||
expect(tracer.findSpan("users.getUserByUsername")!.attributes.found).toBe(true);
|
||||
expect(tracer.findSpan("users.getUserByUsername")!.attributes.found).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("createUser records created=true", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const repo = new MockUsersRepository([], tracer);
|
||||
await repo.createUser({ id: "u1", username: "charlie", passwordHash: "hash" });
|
||||
await repo.createUser({
|
||||
id: "u1",
|
||||
username: "charlie",
|
||||
passwordHash: "hash",
|
||||
});
|
||||
expect(tracer.findSpan("users.createUser")).toBeDefined();
|
||||
expect(tracer.findSpan("users.createUser")!.attributes.created).toBe(true);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { User } from "../../entities/models/user";
|
||||
// generic session interface without deep integration with Payload's REST/local
|
||||
// API and cookie infrastructure.
|
||||
//
|
||||
// TODO(lazar-conformance §7): Implement these three methods once the session
|
||||
// TODO: Implement these three methods once the session
|
||||
// cookie strategy is settled. Until then they throw NotImplementedError to
|
||||
// keep the production-shaped file in place without silently no-oping.
|
||||
//
|
||||
@@ -21,7 +21,7 @@ import type { User } from "../../entities/models/user";
|
||||
|
||||
class NotImplementedError extends Error {
|
||||
constructor(method: string) {
|
||||
super(`NotImplemented: AuthenticationService.${method} — see refactor log §7`);
|
||||
super(`NotImplemented: AuthenticationService.${method}`);
|
||||
this.name = "NotImplementedError";
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,17 @@ export class AuthenticationService implements IAuthenticationService {
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.randomBytes(SALT_LENGTH).toString("hex");
|
||||
const hash = await new Promise<string>((resolve, reject) => {
|
||||
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey.toString("hex"));
|
||||
});
|
||||
crypto.pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
ITERATIONS,
|
||||
KEY_LENGTH,
|
||||
DIGEST,
|
||||
(err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey.toString("hex"));
|
||||
},
|
||||
);
|
||||
});
|
||||
return `${salt}${SEPARATOR}${hash}`;
|
||||
}
|
||||
@@ -56,10 +63,17 @@ export class AuthenticationService implements IAuthenticationService {
|
||||
const salt = parts[0]!;
|
||||
const expectedHash = parts[1]!;
|
||||
const actualHash = await new Promise<string>((resolve, reject) => {
|
||||
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey.toString("hex"));
|
||||
});
|
||||
crypto.pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
ITERATIONS,
|
||||
KEY_LENGTH,
|
||||
DIGEST,
|
||||
(err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey.toString("hex"));
|
||||
},
|
||||
);
|
||||
});
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expectedHash, "hex"),
|
||||
@@ -67,23 +81,27 @@ export class AuthenticationService implements IAuthenticationService {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(lazar-conformance §7): Implement using Payload's local.login / JWT session issuance.
|
||||
// TODO:Implement using Payload's local.login / JWT session issuance.
|
||||
// Payload creates sessions via its REST auth endpoint; mapping that to a
|
||||
// generic { session: Session; cookie: Cookie } shape requires understanding
|
||||
// the JWT payload structure and the cookie name/attributes Payload uses.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async createSession(user: User): Promise<{ session: Session; cookie: Cookie }> {
|
||||
async createSession(
|
||||
user: User,
|
||||
): Promise<{ session: Session; cookie: Cookie }> {
|
||||
throw new NotImplementedError("createSession");
|
||||
}
|
||||
|
||||
// TODO(lazar-conformance §7): Implement using Payload's JWT verify mechanism.
|
||||
// TODO:Implement using Payload's JWT verify mechanism.
|
||||
// Need to call Payload's local API to verify the token and retrieve the user.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async validateSession(sessionId: string): Promise<{ user: User; session: Session }> {
|
||||
async validateSession(
|
||||
sessionId: string,
|
||||
): Promise<{ user: User; session: Session }> {
|
||||
throw new NotImplementedError("validateSession");
|
||||
}
|
||||
|
||||
// TODO(lazar-conformance §7): Implement by clearing the session token.
|
||||
// TODO:Implement by clearing the session token.
|
||||
// Payload does not have a server-side session store by default; invalidation
|
||||
// is typically done client-side by clearing the cookie.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("authRouter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("authRouter (R26 error mapping)", () => {
|
||||
describe("authRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
@@ -39,7 +39,9 @@ describe("authRouter (R26 error mapping)", () => {
|
||||
}
|
||||
const users = new MockUsersRepository();
|
||||
const auth = new MockAuthenticationService(users);
|
||||
authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
|
||||
authContainer
|
||||
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
||||
.toConstantValue(users);
|
||||
authContainer
|
||||
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
||||
.toConstantValue(auth);
|
||||
|
||||
@@ -76,9 +76,7 @@ export const articlesRepositoryContract =
|
||||
|
||||
it("getArticles filters by status", async () => {
|
||||
await repo.createArticle(articleFactory.build({ status: "draft" }));
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ status: "published" }),
|
||||
);
|
||||
await repo.createArticle(articleFactory.build({ status: "published" }));
|
||||
const drafts = await repo.getArticles({ status: "draft" });
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0]?.status).toBe("draft");
|
||||
@@ -117,7 +115,7 @@ export const articlesRepositoryContract =
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("span emission (R50)", () => {
|
||||
describe("span emission", () => {
|
||||
it("getArticles emits articles.getArticles span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { createArticleUseCase, createArticleOutputSchema } from "@/application/use-cases/create-article.use-case";
|
||||
import {
|
||||
createArticleUseCase,
|
||||
createArticleOutputSchema,
|
||||
} from "@/application/use-cases/create-article.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
@@ -37,7 +40,7 @@ describe("createArticleUseCase", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createArticleUseCase output validation (R25)", () => {
|
||||
describe("createArticleUseCase output validation", () => {
|
||||
it("throws when repository returns a malformed article", async () => {
|
||||
const repo = {
|
||||
createArticle: async () => ({ id: 1 }) as unknown as never,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { getArticleBySlugUseCase, getArticleBySlugOutputSchema } from "@/application/use-cases/get-article-by-slug.use-case";
|
||||
import {
|
||||
getArticleBySlugUseCase,
|
||||
getArticleBySlugOutputSchema,
|
||||
} from "@/application/use-cases/get-article-by-slug.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { ArticleNotFoundError } from "@/entities/errors/article";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
@@ -21,11 +24,13 @@ describe("getArticleBySlugUseCase", () => {
|
||||
it("throws ArticleNotFoundError when slug is missing", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(ArticleNotFoundError);
|
||||
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(
|
||||
ArticleNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArticleBySlugUseCase output validation (R25)", () => {
|
||||
describe("getArticleBySlugUseCase output validation", () => {
|
||||
it("throws when repository returns a malformed article", async () => {
|
||||
const repo = {
|
||||
getArticleBySlug: async () => ({ id: 123 }) as unknown as never,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { getArticlesUseCase, getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case";
|
||||
import {
|
||||
getArticlesUseCase,
|
||||
getArticlesOutputSchema,
|
||||
} from "@/application/use-cases/get-articles.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
@@ -8,7 +11,9 @@ describe("getArticlesUseCase", () => {
|
||||
it("returns all articles with no filters", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ id: "1", title: "A", slug: "a" }),
|
||||
);
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const result = await useCase({});
|
||||
@@ -19,8 +24,17 @@ describe("getArticlesUseCase", () => {
|
||||
it("filters by status", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }));
|
||||
await repo.createArticle(articleFactory.build({ id: "2", title: "B", slug: "b", status: "published" }));
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }),
|
||||
);
|
||||
await repo.createArticle(
|
||||
articleFactory.build({
|
||||
id: "2",
|
||||
title: "B",
|
||||
slug: "b",
|
||||
status: "published",
|
||||
}),
|
||||
);
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const result = await useCase({ status: "published" });
|
||||
@@ -29,7 +43,7 @@ describe("getArticlesUseCase", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArticlesUseCase output validation (R25)", () => {
|
||||
describe("getArticlesUseCase output validation", () => {
|
||||
it("throws when the repository returns a malformed article", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
// bypass the mock's createArticle (which is typed) by reaching into _articles directly
|
||||
|
||||
@@ -40,8 +40,12 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
blogContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
blogContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
blogContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
blogContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
||||
@@ -93,11 +97,15 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
||||
]) {
|
||||
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||
}
|
||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticlesUseCase)
|
||||
.toConstantValue(wrappedGetArticles);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
|
||||
.toConstantValue(wrappedGetArticleBySlug);
|
||||
blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.ICreateArticleUseCase)
|
||||
.toConstantValue(wrappedCreateArticle);
|
||||
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticlesController)
|
||||
@@ -120,7 +128,11 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
|
||||
{
|
||||
feature: "blog",
|
||||
layer: "controller",
|
||||
name: "blog.getArticleBySlug",
|
||||
},
|
||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
||||
),
|
||||
),
|
||||
@@ -138,8 +150,7 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -19,7 +19,8 @@ import { getArticleBySlugController } from "../interface-adapters/controllers/ge
|
||||
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
|
||||
|
||||
export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
||||
ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
@@ -28,8 +29,12 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
blogContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
blogContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
blogContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
blogContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
// Real repository
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||
@@ -38,7 +43,7 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
const repo = new ArticlesRepository(config, tracer, logger);
|
||||
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
|
||||
|
||||
// Use cases — wrapped with span + capture at bind time (R41, R44)
|
||||
// Use cases — wrapped with span + capture at bind time
|
||||
const wrappedGetArticles = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "use-case" },
|
||||
@@ -76,11 +81,15 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleUseCase)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleUseCase);
|
||||
}
|
||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticlesUseCase).toConstantValue(wrappedGetArticles);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticlesUseCase)
|
||||
.toConstantValue(wrappedGetArticles);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase)
|
||||
.toConstantValue(wrappedGetArticleBySlug);
|
||||
blogContainer.bind(BLOG_SYMBOLS.ICreateArticleUseCase).toConstantValue(wrappedCreateArticle);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.ICreateArticleUseCase)
|
||||
.toConstantValue(wrappedCreateArticle);
|
||||
|
||||
// Controllers — wrapped with span at bind time
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) {
|
||||
@@ -113,7 +122,11 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
|
||||
{
|
||||
feature: "blog",
|
||||
layer: "controller",
|
||||
name: "blog.getArticleBySlug",
|
||||
},
|
||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
||||
),
|
||||
),
|
||||
@@ -131,8 +144,7 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -3,7 +3,7 @@ export type { BlogRouter } from "./integrations/api/router";
|
||||
export { ArticleNotFoundError } from "./entities/errors/article";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
|
||||
// Use case schemas + types (Plan 9 R18)
|
||||
// Use case schemas + types
|
||||
export {
|
||||
getArticlesInputSchema,
|
||||
getArticlesOutputSchema,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
|
||||
// Mock repo also wraps in spans (R42); easier to assert without booting Payload.
|
||||
describe("MockArticlesRepository emits spans (R42)", () => {
|
||||
// Mock repo also wraps in spans; easier to assert without booting Payload.
|
||||
describe("MockArticlesRepository emits spans", () => {
|
||||
it("getArticles emits one span with op='repository'", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
@@ -31,7 +34,9 @@ describe("MockArticlesRepository emits spans (R42)", () => {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(tracer.findSpan("articles.createArticle")).toBeDefined();
|
||||
expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe("t");
|
||||
expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe(
|
||||
"t",
|
||||
);
|
||||
});
|
||||
|
||||
it("getArticle records found=false for missing id", async () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("blogRouter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("blogRouter (R26 error mapping)", () => {
|
||||
describe("blogRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// React Query option builders for blog feature procedures.
|
||||
// Consumed by apps via the @repo/core-trpc client (wired in Plan 5).
|
||||
// Consumed by apps via the @repo/core-trpc client.
|
||||
|
||||
type TrpcClient = {
|
||||
blog: {
|
||||
@@ -23,7 +23,12 @@ export function articleBySlugQuery(client: TrpcClient, slug: string) {
|
||||
|
||||
export function listArticlesQuery(
|
||||
client: TrpcClient,
|
||||
options?: { status?: string; authorId?: string; limit?: number; offset?: number },
|
||||
options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) {
|
||||
return client.blog.listArticles.queryOptions(options);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// R44 — verify the full chain (controller → use case → repo) wraps with
|
||||
// Verify the full chain (controller → use case → repo) wraps with
|
||||
// withSpan + withCapture and never double-captures the same error.
|
||||
//
|
||||
// Each layer's withCapture catch checks the __sentryReported flag (set by
|
||||
@@ -16,7 +16,7 @@ import { MockArticlesRepository } from "../src/infrastructure/repositories/artic
|
||||
import { getArticleBySlugUseCase } from "../src/application/use-cases/get-article-by-slug.use-case";
|
||||
import { getArticleBySlugController } from "../src/interface-adapters/controllers/get-article-by-slug.controller";
|
||||
|
||||
describe("R44 — no double-capture across span/capture-wrapped layers", () => {
|
||||
describe("no double-capture across span/capture-wrapped layers", () => {
|
||||
it("an error originated in the repo is captured exactly once with repo tags", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
@@ -30,7 +30,11 @@ describe("R44 — no double-capture across span/capture-wrapped layers", () => {
|
||||
// Mirror what the real repo does: capture with repo tags, mark the
|
||||
// flag (RecordingLogger.captureException does this for us now).
|
||||
logger.captureException(err, {
|
||||
tags: { feature: "blog", repo: "articles", method: "getArticleBySlug" },
|
||||
tags: {
|
||||
feature: "blog",
|
||||
repo: "articles",
|
||||
method: "getArticleBySlug",
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
};
|
||||
@@ -58,9 +62,11 @@ describe("R44 — no double-capture across span/capture-wrapped layers", () => {
|
||||
),
|
||||
);
|
||||
|
||||
await expect(wrappedCtrl({ slug: "anything" })).rejects.toThrow("boom from repo");
|
||||
await expect(wrappedCtrl({ slug: "anything" })).rejects.toThrow(
|
||||
"boom from repo",
|
||||
);
|
||||
|
||||
// R44: exactly one capture. Outer wrappers saw the flag and skipped.
|
||||
// Exactly one capture. Outer wrappers saw the flag and skipped.
|
||||
expect(logger.captures).toHaveLength(1);
|
||||
const only = logger.captures[0];
|
||||
expect(only?.kind).toBe("exception");
|
||||
|
||||
@@ -26,7 +26,7 @@ export type BindAuditOpts = {
|
||||
* if not — better to refuse to start than to ship audit data with a dev-fallback
|
||||
* salt that an attacker could reverse.
|
||||
*
|
||||
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog (Phase 4)
|
||||
* The returned auditLog is wrapped in TraceIdEnrichingAuditLog
|
||||
* so all sinks receive AuditEntry.correlationId auto-populated from the
|
||||
* active OTel span. The inner sink/fan-out is accessible via `.inner`.
|
||||
*/
|
||||
@@ -34,7 +34,10 @@ export function bindAudit(
|
||||
container: Container,
|
||||
opts: BindAuditOpts = {},
|
||||
): { auditLog: IAuditLog } {
|
||||
if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) {
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!process.env.AUDIT_PSEUDONYM_SALT
|
||||
) {
|
||||
throw new Error(
|
||||
"AUDIT_PSEUDONYM_SALT environment variable is required in production. " +
|
||||
"Generate via `openssl rand -hex 32` and store in your secrets manager.",
|
||||
@@ -52,9 +55,11 @@ export function bindAudit(
|
||||
}
|
||||
|
||||
const inner: IAuditLog =
|
||||
sinks.length > 1 ? new MultiSinkAuditLog(sinks)
|
||||
: sinks.length === 1 ? sinks[0]!
|
||||
: new NoopAuditLog();
|
||||
sinks.length > 1
|
||||
? new MultiSinkAuditLog(sinks)
|
||||
: sinks.length === 1
|
||||
? sinks[0]!
|
||||
: new NoopAuditLog();
|
||||
const auditLog: IAuditLog = new TraceIdEnrichingAuditLog(inner);
|
||||
|
||||
if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export type { IAuditLog } from "./audit-log.interface";
|
||||
export type { AuditEntry, AuditAction, AuditFrom } from "@repo/core-shared/audit";
|
||||
export type {
|
||||
AuditEntry,
|
||||
AuditAction,
|
||||
AuditFrom,
|
||||
} from "@repo/core-shared/audit";
|
||||
export { NoopAuditLog } from "./noop-audit-log";
|
||||
export { StdoutJsonAuditLog } from "./stdout-json-audit-log";
|
||||
export { PayloadAuditLog } from "./payload-audit-log";
|
||||
@@ -8,17 +12,14 @@ export { auditLogsCollection } from "./audit-logs-collection";
|
||||
export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
|
||||
export { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
|
||||
export { AUDIT_SYMBOLS } from "./di/symbols";
|
||||
// Phase 3 — GDPR erasure
|
||||
// GDPR erasure
|
||||
export { pseudonymize } from "./pseudonymize";
|
||||
export {
|
||||
createAuditErasureHook,
|
||||
type AuditErasureHookOpts,
|
||||
} from "./hooks/audit-erasure-hook";
|
||||
// Phase 5 — VIEW capture
|
||||
export {
|
||||
createAuditAfterReadHook,
|
||||
type AuditAfterReadHookOpts,
|
||||
} from "./hooks";
|
||||
// VIEW capture
|
||||
export { createAuditAfterReadHook, type AuditAfterReadHookOpts } from "./hooks";
|
||||
export {
|
||||
createAuditRouter,
|
||||
auditRouter,
|
||||
|
||||
@@ -21,7 +21,7 @@ export type Audited<F> = F & { readonly __audited: true };
|
||||
* tests).
|
||||
*/
|
||||
export function withAudit<Args extends unknown[], R>(
|
||||
// TODO(conformance milestone iii+): wire automated recording from manifest
|
||||
// TODO: wire automated recording from manifest declarations.
|
||||
// `audits[]` declarations. For now, the wrapper exists to:
|
||||
// (1) require callers to pass the auditLog at bind time (dep is available)
|
||||
// (2) attach the `__audited` brand so the boot-time assertion can verify
|
||||
|
||||
@@ -29,7 +29,7 @@ export type AuditFrom = {
|
||||
*/
|
||||
export type AuditEntry = {
|
||||
// WHO
|
||||
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name (R36). */
|
||||
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name. */
|
||||
actorId: string;
|
||||
actorType: "user" | "system" | "service";
|
||||
/** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */
|
||||
|
||||
@@ -5,7 +5,10 @@ import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
|
||||
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
|
||||
import { buildResource } from "./resource";
|
||||
import { createSentryOtelBridge } from "./sentry-bridge";
|
||||
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
|
||||
import {
|
||||
PiiScrubSpanProcessor,
|
||||
PiiScrubLogRecordProcessor,
|
||||
} from "./pii-scrub-processor";
|
||||
|
||||
const { BatchSpanProcessor } = tracing;
|
||||
|
||||
@@ -47,7 +50,7 @@ export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
|
||||
// `as never` works around a TypeScript version conflict: `core-shared`'s direct
|
||||
// dep on `@opentelemetry/sdk-trace-base@1.30.1` has subtly incompatible types
|
||||
// vs the 1.28.0 bundled by `sdk-node@0.55.0`. The runtime objects are compatible;
|
||||
// the structural mismatch is type-only. Phase 1 implementer chose this rather than
|
||||
// the structural mismatch is type-only — chosen rather than
|
||||
// constraining sdk-trace-base to 1.28.x to avoid losing future bug fixes.
|
||||
new BatchSpanProcessor(bridge.spanProcessor as never),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// packages/core-shared/src/instrumentation/otel/pii-fields.ts
|
||||
|
||||
// R32 — substring match on event keys (case-insensitive).
|
||||
// Substring match on event keys (case-insensitive).
|
||||
// IP address attribute KEYS from OTel HttpInstrumentation (semconv 1.20 and 1.27+)
|
||||
// are listed here so they are key-redacted in addition to the value-level regex
|
||||
// scrubbing in pii-scrub-processor.ts.
|
||||
@@ -28,7 +28,7 @@ export const PII_KEY_SUBSTRINGS = [
|
||||
"host.ip",
|
||||
] as const;
|
||||
|
||||
// R33 — substring match on URL query-param keys (case-insensitive)
|
||||
// Substring match on URL query-param keys (case-insensitive)
|
||||
export const PII_QUERY_PARAM_SUBSTRINGS = [
|
||||
"token",
|
||||
"email",
|
||||
|
||||
@@ -10,11 +10,17 @@ import {
|
||||
SimpleLogRecordProcessor,
|
||||
} from "@opentelemetry/sdk-logs";
|
||||
import { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
|
||||
import {
|
||||
PiiScrubSpanProcessor,
|
||||
PiiScrubLogRecordProcessor,
|
||||
} from "./pii-scrub-processor";
|
||||
|
||||
const spanExporter = new InMemorySpanExporter();
|
||||
const tracerProvider = new BasicTracerProvider({
|
||||
spanProcessors: [new PiiScrubSpanProcessor(), new SimpleSpanProcessor(spanExporter)],
|
||||
spanProcessors: [
|
||||
new PiiScrubSpanProcessor(),
|
||||
new SimpleSpanProcessor(spanExporter),
|
||||
],
|
||||
});
|
||||
|
||||
// Use addLogRecordProcessor to chain processors in the right order.
|
||||
@@ -43,7 +49,7 @@ describe("PiiScrubSpanProcessor", () => {
|
||||
const exported = spanExporter.getFinishedSpans();
|
||||
expect(exported[0]!.attributes["user.email"]).toBe("[redacted]");
|
||||
expect(exported[0]!.attributes["auth.token"]).toBe("[redacted]");
|
||||
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine per R36
|
||||
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine
|
||||
expect(exported[0]!.attributes["request.path"]).toBe("/api/users");
|
||||
});
|
||||
|
||||
@@ -119,7 +125,7 @@ describe("PiiScrubLogRecordProcessor", () => {
|
||||
expect(records[0]!.body).toBe("user signed in successfully");
|
||||
});
|
||||
|
||||
it("scrubs IPv4 in log record body (C2 / R32)", () => {
|
||||
it("scrubs IPv4 in log record body", () => {
|
||||
const logger = logProvider.getLogger("test");
|
||||
logger.emit({
|
||||
severityNumber: SeverityNumber.INFO,
|
||||
@@ -132,7 +138,7 @@ describe("PiiScrubLogRecordProcessor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
|
||||
describe("PiiScrubSpanProcessor — IP address scrubbing", () => {
|
||||
it("scrubs IPv4 addresses in attribute values", () => {
|
||||
const tracer = tracerProvider.getTracer("test");
|
||||
const span = tracer.startSpan("test-span", {
|
||||
@@ -140,7 +146,9 @@ describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
|
||||
});
|
||||
span.end();
|
||||
const exported = spanExporter.getFinishedSpans();
|
||||
expect(exported[0]!.attributes["request.note"]).toBe("request from [redacted-ip]");
|
||||
expect(exported[0]!.attributes["request.note"]).toBe(
|
||||
"request from [redacted-ip]",
|
||||
);
|
||||
});
|
||||
|
||||
it("scrubs IPv6 addresses in attribute values", () => {
|
||||
|
||||
@@ -3,14 +3,23 @@
|
||||
// PII scrub processors for OTel spans and log records.
|
||||
// These run FIRST in their respective processor chains so downstream exporters
|
||||
// (including the Sentry exporter) see scrubbed data. This replaces the old
|
||||
// Sentry beforeSend / beforeSendTransaction hooks (R32, R33) — scrubbing now
|
||||
// Sentry beforeSend / beforeSendTransaction hooks — scrubbing now
|
||||
// happens at the OTel layer, vendor-agnostic.
|
||||
|
||||
import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import type {
|
||||
ReadableSpan,
|
||||
SpanProcessor,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import type { Span } from "@opentelemetry/api";
|
||||
import type { Context } from "@opentelemetry/api";
|
||||
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
|
||||
import { PII_KEY_SUBSTRINGS, REDACTED_VALUE, IPV4_REGEX, IPV6_REGEX, REDACTED_IP } from "./pii-fields";
|
||||
import {
|
||||
PII_KEY_SUBSTRINGS,
|
||||
REDACTED_VALUE,
|
||||
IPV4_REGEX,
|
||||
IPV6_REGEX,
|
||||
REDACTED_IP,
|
||||
} from "./pii-fields";
|
||||
|
||||
function isPiiKey(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
@@ -27,7 +36,7 @@ function containsPiiSubstring(s: string): boolean {
|
||||
* Called for attribute values whose KEYS did not match a PII substring — the
|
||||
* old Sentry beforeSend hook performed this kind of value-level scrubbing; we
|
||||
* replicate it here so IP addresses embedded in non-IP-keyed attributes
|
||||
* (e.g. "request.note": "from 10.0.0.1") are still redacted (C2 fix / R32).
|
||||
* (e.g. "request.note": "from 10.0.0.1") are still redacted.
|
||||
*/
|
||||
function scrubValue(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
@@ -39,7 +48,9 @@ function scrubValue(value: unknown): unknown {
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown> {
|
||||
function scrubAttributes(
|
||||
attrs: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (isPiiKey(key)) {
|
||||
@@ -54,7 +65,7 @@ function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown
|
||||
/**
|
||||
* Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes.
|
||||
* Redacts any span attribute whose key contains a PII substring (case-insensitive).
|
||||
* R32 — attribute-key-based PII redaction.
|
||||
* Attribute-key-based PII redaction.
|
||||
*/
|
||||
export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||
forceFlush(): Promise<void> {
|
||||
@@ -70,7 +81,9 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||
}
|
||||
|
||||
onEnd(span: ReadableSpan): void {
|
||||
const scrubbed = scrubAttributes(span.attributes as Record<string, unknown>);
|
||||
const scrubbed = scrubAttributes(
|
||||
span.attributes as Record<string, unknown>,
|
||||
);
|
||||
Object.assign(span.attributes, scrubbed);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +93,7 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||
* - Strips PII from attributes (key-based substring match, case-insensitive).
|
||||
* - Strips PII from the log body string (substring match — if any PII substring
|
||||
* appears in the body, the entire body is redacted to avoid partial leakage).
|
||||
* R32 — attribute-key-based PII redaction; R33 — body-level redaction.
|
||||
* Attribute-key-based PII redaction; body-level redaction.
|
||||
*/
|
||||
export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
||||
forceFlush(): Promise<void> {
|
||||
@@ -93,7 +106,9 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
||||
|
||||
onEmit(record: LogRecord): void {
|
||||
if (record.attributes) {
|
||||
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
|
||||
const scrubbed = scrubAttributes(
|
||||
record.attributes as Record<string, unknown>,
|
||||
);
|
||||
Object.assign(record.attributes, scrubbed);
|
||||
}
|
||||
if (typeof record.body === "string") {
|
||||
@@ -103,7 +118,7 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
||||
record.body = REDACTED_VALUE;
|
||||
} else {
|
||||
// No PII keyword, but may still contain IP addresses embedded in text.
|
||||
// Apply value-level regex scrubbing (C2 fix / R32).
|
||||
// Apply value-level regex scrubbing.
|
||||
record.body = scrubValue(record.body) as string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("createSentryOtelBridge", () => {
|
||||
const { createSentryOtelBridge } = await import("./sentry-bridge");
|
||||
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
|
||||
expect(bridge.spanProcessor).toBeDefined();
|
||||
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
|
||||
// logRecordProcessor is wired (SentryLogRecordForwarder)
|
||||
expect(bridge.logRecordProcessor).toBeDefined();
|
||||
expect(bridge.logRecordProcessor).not.toBeNull();
|
||||
});
|
||||
@@ -50,7 +50,10 @@ describe("SentryLogRecordForwarder", () => {
|
||||
|
||||
const captureException = vi.fn();
|
||||
const captureMessage = vi.fn();
|
||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
||||
const forwarder = new SentryLogRecordForwarder({
|
||||
captureException,
|
||||
captureMessage,
|
||||
});
|
||||
|
||||
const record = {
|
||||
severityNumber: SEVERITY_ERROR,
|
||||
@@ -84,7 +87,10 @@ describe("SentryLogRecordForwarder", () => {
|
||||
|
||||
const captureException = vi.fn();
|
||||
const captureMessage = vi.fn();
|
||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
||||
const forwarder = new SentryLogRecordForwarder({
|
||||
captureException,
|
||||
captureMessage,
|
||||
});
|
||||
|
||||
const record = {
|
||||
severityNumber: SEVERITY_ERROR,
|
||||
@@ -110,7 +116,10 @@ describe("SentryLogRecordForwarder", () => {
|
||||
|
||||
const captureException = vi.fn();
|
||||
const captureMessage = vi.fn();
|
||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
||||
const forwarder = new SentryLogRecordForwarder({
|
||||
captureException,
|
||||
captureMessage,
|
||||
});
|
||||
|
||||
const record = {
|
||||
severityNumber,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { tracing as sdkTracing, logs as sdkLogs } from "@opentelemetry/sdk-node";
|
||||
import type {
|
||||
tracing as sdkTracing,
|
||||
logs as sdkLogs,
|
||||
} from "@opentelemetry/sdk-node";
|
||||
import { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import type { LogRecord } from "@opentelemetry/sdk-logs";
|
||||
|
||||
@@ -64,7 +67,8 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
||||
if (severityNumber >= SeverityNumber.ERROR) {
|
||||
// Reconstruct the error from OTel semantic convention attributes
|
||||
const message =
|
||||
(attrs["exception.message"] as string | undefined) ?? String(record.body ?? "");
|
||||
(attrs["exception.message"] as string | undefined) ??
|
||||
String(record.body ?? "");
|
||||
const err = new Error(message);
|
||||
if (attrs["exception.type"]) {
|
||||
err.name = attrs["exception.type"] as string;
|
||||
@@ -77,7 +81,11 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
||||
? (attrs["sentry.fingerprint"] as string).split("|")
|
||||
: undefined;
|
||||
|
||||
Sentry.captureException(err, { tags, extra, ...(fingerprint ? { fingerprint } : {}) });
|
||||
Sentry.captureException(err, {
|
||||
tags,
|
||||
extra,
|
||||
...(fingerprint ? { fingerprint } : {}),
|
||||
});
|
||||
} else {
|
||||
// Map severity to Sentry level
|
||||
const level = severityNumber >= SeverityNumber.WARN ? "warning" : "info";
|
||||
@@ -99,9 +107,11 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
||||
* Creates Sentry-as-OTel-exporter processors. The OTel SDK uses these to
|
||||
* forward spans and log records to Sentry. This is the ONLY file in
|
||||
* core-shared that imports from `@sentry/opentelemetry` — all other Sentry
|
||||
* coupling is excluded by the R40/R52 ESLint allowlist.
|
||||
* coupling is excluded by the ESLint allowlist.
|
||||
*/
|
||||
export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBridge {
|
||||
export function createSentryOtelBridge(
|
||||
opts: SentryOtelBridgeOpts,
|
||||
): SentryOtelBridge {
|
||||
if (!opts.dsn) {
|
||||
return { spanProcessor: null, logRecordProcessor: null };
|
||||
}
|
||||
@@ -125,7 +135,9 @@ function extractTags(attrs: Record<string, unknown>): Record<string, string> {
|
||||
return tags;
|
||||
}
|
||||
|
||||
function extractExtras(attrs: Record<string, unknown>): Record<string, unknown> {
|
||||
function extractExtras(
|
||||
attrs: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const extras: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k.startsWith("extra.")) {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts }));
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration };
|
||||
});
|
||||
|
||||
@@ -19,43 +22,35 @@ describe("initSentryClientReact", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls SentryReact.init with sendDefaultPii: false (R31)", () => {
|
||||
it("calls SentryReact.init with sendDefaultPii: false", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["sendDefaultPii"]).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches replay integration with mask flags (R34, R35)", () => {
|
||||
it("attaches replay integration with mask flags", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(replayOpts["maskAllText"]).toBe(true);
|
||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||
expect(replayOpts["blockAllMedia"]).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults replay sample rates per R37", () => {
|
||||
it("defaults replay sample rates", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
||||
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(typeof call["beforeSend"]).toBe("function");
|
||||
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IPV6_REGEX,
|
||||
} from "../otel/pii-fields";
|
||||
|
||||
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
|
||||
// Inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
|
||||
function keyContainsPii(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
|
||||
@@ -33,7 +33,9 @@ function redactString(s: string): string {
|
||||
function deepScrub(value: unknown, parentKey = ""): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === "string") {
|
||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
|
||||
return parentKey && keyContainsPii(parentKey)
|
||||
? REDACTED_VALUE
|
||||
: redactString(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
||||
@@ -63,8 +65,8 @@ function scrubUrl(url: string): string {
|
||||
|
||||
/**
|
||||
* Client-side init for non-Next.js (Vite/React) runtimes (TanStack Start).
|
||||
* Mirrors init-client.ts but uses @sentry/react directly. R31, R32, R33,
|
||||
* R34, R35, R37 still apply.
|
||||
* Mirrors init-client.ts but uses @sentry/react directly. Same PII,
|
||||
* replay, and scrubbing requirements apply.
|
||||
*/
|
||||
export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
if (!opts.dsn) return;
|
||||
@@ -78,31 +80,48 @@ export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
: 1.0;
|
||||
|
||||
const environment =
|
||||
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
|
||||
process.env["SENTRY_ENVIRONMENT"] ??
|
||||
process.env["NODE_ENV"] ??
|
||||
"development";
|
||||
const release = opts.release ?? "unknown";
|
||||
|
||||
type InitOpts = Parameters<typeof SentryReact.init>[0];
|
||||
type SentryEvent = { extra?: Record<string, unknown> | null; contexts?: Record<string, Record<string, unknown> | undefined>; request?: { url?: string; headers?: Record<string, string | undefined>; [key: string]: unknown }; transaction?: string; [key: string]: unknown };
|
||||
type SentryEvent = {
|
||||
extra?: Record<string, unknown> | null;
|
||||
contexts?: Record<string, Record<string, unknown> | undefined>;
|
||||
request?: {
|
||||
url?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
transaction?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
SentryReact.init({
|
||||
dsn: opts.dsn,
|
||||
environment,
|
||||
release,
|
||||
tracesSampleRate,
|
||||
sendDefaultPii: false, // R31
|
||||
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"], // R32
|
||||
beforeSendTransaction: ((event: SentryEvent) => { // R33
|
||||
sendDefaultPii: false,
|
||||
beforeSend: ((event: SentryEvent) =>
|
||||
deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"],
|
||||
beforeSendTransaction: ((event: SentryEvent) => {
|
||||
const out = { ...event };
|
||||
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
||||
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
|
||||
if (out.request?.url)
|
||||
out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
||||
if (
|
||||
out.transaction &&
|
||||
(out.transaction.includes("?") || out.transaction.includes("="))
|
||||
) {
|
||||
out.transaction = scrubUrl(out.transaction);
|
||||
}
|
||||
return out;
|
||||
}) as unknown as NonNullable<InitOpts>["beforeSendTransaction"],
|
||||
replaysSessionSampleRate: 0.0, // R37
|
||||
replaysOnErrorSampleRate: 1.0, // R37
|
||||
replaysSessionSampleRate: 0.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// R34, R35 — mandatory mask flags; allowlist starts empty
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
SentryReact.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts }));
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration };
|
||||
});
|
||||
|
||||
@@ -19,51 +22,41 @@ describe("initSentryClient", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
|
||||
it("calls Sentry.init with sendDefaultPii: false", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["sendDefaultPii"]).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true (R34, R35)", () => {
|
||||
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(replayOpts["maskAllText"]).toBe(true);
|
||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||
expect(replayOpts["blockAllMedia"]).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults replaysSessionSampleRate to 0.0 (R37)", () => {
|
||||
it("defaults replaysSessionSampleRate to 0.0", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
||||
});
|
||||
|
||||
it("defaults replaysOnErrorSampleRate to 1.0 (R37)", () => {
|
||||
it("defaults replaysOnErrorSampleRate to 1.0", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(typeof call["beforeSend"]).toBe("function");
|
||||
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export type InitClientOpts = {
|
||||
release?: string;
|
||||
};
|
||||
|
||||
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
|
||||
// Inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
|
||||
function keyContainsPii(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
|
||||
@@ -38,7 +38,9 @@ function redactString(s: string): string {
|
||||
function deepScrub(value: unknown, parentKey = ""): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === "string") {
|
||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
|
||||
return parentKey && keyContainsPii(parentKey)
|
||||
? REDACTED_VALUE
|
||||
: redactString(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
||||
@@ -78,31 +80,48 @@ export function initSentryClient(opts: InitClientOpts): void {
|
||||
: 1.0;
|
||||
|
||||
const environment =
|
||||
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
|
||||
process.env["SENTRY_ENVIRONMENT"] ??
|
||||
process.env["NODE_ENV"] ??
|
||||
"development";
|
||||
const release = opts.release ?? "unknown";
|
||||
|
||||
type InitOpts = Parameters<typeof Sentry.init>[0];
|
||||
type SentryEvent = { extra?: Record<string, unknown> | null; contexts?: Record<string, Record<string, unknown> | undefined>; request?: { url?: string; headers?: Record<string, string | undefined>; [key: string]: unknown }; transaction?: string; [key: string]: unknown };
|
||||
type SentryEvent = {
|
||||
extra?: Record<string, unknown> | null;
|
||||
contexts?: Record<string, Record<string, unknown> | undefined>;
|
||||
request?: {
|
||||
url?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
transaction?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
Sentry.init({
|
||||
dsn: opts.dsn,
|
||||
environment,
|
||||
release,
|
||||
tracesSampleRate,
|
||||
sendDefaultPii: false, // R31
|
||||
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as InitOpts["beforeSend"], // R32
|
||||
beforeSendTransaction: ((event: SentryEvent) => { // R33
|
||||
sendDefaultPii: false,
|
||||
beforeSend: ((event: SentryEvent) =>
|
||||
deepScrub(event)) as unknown as InitOpts["beforeSend"],
|
||||
beforeSendTransaction: ((event: SentryEvent) => {
|
||||
const out = { ...event };
|
||||
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
||||
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
|
||||
if (out.request?.url)
|
||||
out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
||||
if (
|
||||
out.transaction &&
|
||||
(out.transaction.includes("?") || out.transaction.includes("="))
|
||||
) {
|
||||
out.transaction = scrubUrl(out.transaction);
|
||||
}
|
||||
return out;
|
||||
}) as unknown as InitOpts["beforeSendTransaction"],
|
||||
replaysSessionSampleRate: 0.0, // R37 — privacy default
|
||||
replaysOnErrorSampleRate: 1.0, // R37
|
||||
replaysSessionSampleRate: 0.0, // privacy default
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// R34, R35 — mandatory mask flags; allowlist starts empty
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
|
||||
@@ -6,16 +6,19 @@ interface Adder {
|
||||
add(a: number, b: number): number;
|
||||
}
|
||||
|
||||
const adderContract = defineContractSuite<Adder>("Adder", ({ buildSubject }) => {
|
||||
it("adds two positive numbers", async () => {
|
||||
const subject = await buildSubject();
|
||||
expect(subject.add(2, 3)).toBe(5);
|
||||
});
|
||||
it("handles zero", async () => {
|
||||
const subject = await buildSubject();
|
||||
expect(subject.add(0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
const adderContract = defineContractSuite<Adder>(
|
||||
"Adder",
|
||||
({ buildSubject }) => {
|
||||
it("adds two positive numbers", async () => {
|
||||
const subject = await buildSubject();
|
||||
expect(subject.add(2, 3)).toBe(5);
|
||||
});
|
||||
it("handles zero", async () => {
|
||||
const subject = await buildSubject();
|
||||
expect(subject.add(0, 0)).toBe(0);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
class RealAdder implements Adder {
|
||||
add(a: number, b: number) {
|
||||
@@ -27,17 +30,20 @@ describe("RealAdder satisfies Adder contract", () => {
|
||||
adderContract.run(() => new RealAdder());
|
||||
});
|
||||
|
||||
describe("defineContractSuite — getTracer plumbing (R50)", () => {
|
||||
describe("defineContractSuite — getTracer plumbing", () => {
|
||||
it("passes the tracer accessor into the suite", () => {
|
||||
let receivedTracer: RecordingTracer | undefined;
|
||||
const tracer = new RecordingTracer();
|
||||
const suite = defineContractSuite<{ foo: string }>("Test", ({ buildSubject, getTracer }) => {
|
||||
it("can read tracer", async () => {
|
||||
const subject = await buildSubject();
|
||||
expect(subject.foo).toBe("bar");
|
||||
receivedTracer = getTracer?.();
|
||||
});
|
||||
});
|
||||
const suite = defineContractSuite<{ foo: string }>(
|
||||
"Test",
|
||||
({ buildSubject, getTracer }) => {
|
||||
it("can read tracer", async () => {
|
||||
const subject = await buildSubject();
|
||||
expect(subject.foo).toBe("bar");
|
||||
receivedTracer = getTracer?.();
|
||||
});
|
||||
},
|
||||
);
|
||||
suite.run(() => ({ foo: "bar" }), { tracer: () => tracer });
|
||||
// Vitest defers actual assertion to the `it`; we verify the wiring by re-reading after.
|
||||
// (This is a meta-test of plumbing only — the inner it() runs as a child describe.)
|
||||
@@ -47,12 +53,15 @@ describe("defineContractSuite — getTracer plumbing (R50)", () => {
|
||||
|
||||
it("getTracer is undefined when opts.tracer not provided (backward compat)", () => {
|
||||
let receivedAccessor: unknown = undefined;
|
||||
const suite = defineContractSuite<{ x: number }>("Test", ({ buildSubject, getTracer }) => {
|
||||
it("accessor undefined", async () => {
|
||||
await buildSubject();
|
||||
receivedAccessor = getTracer;
|
||||
});
|
||||
});
|
||||
const suite = defineContractSuite<{ x: number }>(
|
||||
"Test",
|
||||
({ buildSubject, getTracer }) => {
|
||||
it("accessor undefined", async () => {
|
||||
await buildSubject();
|
||||
receivedAccessor = getTracer;
|
||||
});
|
||||
},
|
||||
);
|
||||
suite.run(() => ({ x: 1 }));
|
||||
// No tracer opts → accessor is undefined inside the suite body.
|
||||
// (Exact assertion happens via type, not runtime — typecheck gates this.)
|
||||
|
||||
@@ -44,80 +44,79 @@ export const CONTRACT_PAGES_SEED: Page[] = [
|
||||
* must return a repo pre-loaded with CONTRACT_PAGES_SEED (two pages:
|
||||
* one published with slug "about", one draft with slug "draft-page").
|
||||
*/
|
||||
export const pagesRepositoryContract =
|
||||
defineContractSuite<IPagesRepository>(
|
||||
"IPagesRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IPagesRepository;
|
||||
export const pagesRepositoryContract = defineContractSuite<IPagesRepository>(
|
||||
"IPagesRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IPagesRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repo = await buildSubject();
|
||||
beforeEach(async () => {
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
// --- getPageBySlug ---
|
||||
|
||||
it("getPageBySlug returns the published page by slug", async () => {
|
||||
const result = await repo.getPageBySlug("about");
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.slug).toBe("about");
|
||||
expect(result?.status).toBe("published");
|
||||
expect(result?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("getPageBySlug returns undefined for an unknown slug", async () => {
|
||||
expect(await repo.getPageBySlug("no-such-page")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getPages ---
|
||||
|
||||
it("getPages with no filter returns all seeded pages", async () => {
|
||||
const list = await repo.getPages();
|
||||
expect(list).toHaveLength(2);
|
||||
for (const page of list) {
|
||||
expect(page.id).toBeDefined();
|
||||
expect(page.slug).toBeDefined();
|
||||
expect(["draft", "published"]).toContain(page.status);
|
||||
}
|
||||
});
|
||||
|
||||
it("getPages({ status: 'published' }) returns only published pages", async () => {
|
||||
const published = await repo.getPages({ status: "published" });
|
||||
expect(published).toHaveLength(1);
|
||||
for (const page of published) {
|
||||
expect(page.status).toBe("published");
|
||||
}
|
||||
});
|
||||
|
||||
it("getPages({ status: 'draft' }) returns only draft pages", async () => {
|
||||
const drafts = await repo.getPages({ status: "draft" });
|
||||
expect(drafts).toHaveLength(1);
|
||||
for (const page of drafts) {
|
||||
expect(page.status).toBe("draft");
|
||||
}
|
||||
});
|
||||
|
||||
describe("span emission", () => {
|
||||
it("getPageBySlug emits pages.getPageBySlug span with slug attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getPageBySlug("about");
|
||||
const span = tracer.findSpan("pages.getPageBySlug");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.slug).toBe("about");
|
||||
});
|
||||
|
||||
// --- getPageBySlug ---
|
||||
|
||||
it("getPageBySlug returns the published page by slug", async () => {
|
||||
const result = await repo.getPageBySlug("about");
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.slug).toBe("about");
|
||||
expect(result?.status).toBe("published");
|
||||
expect(result?.id).toBeDefined();
|
||||
it("getPages emits pages.getPages span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getPages({ status: "published" });
|
||||
const span = tracer.findSpan("pages.getPages");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.status).toBe("published");
|
||||
});
|
||||
|
||||
it("getPageBySlug returns undefined for an unknown slug", async () => {
|
||||
expect(await repo.getPageBySlug("no-such-page")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getPages ---
|
||||
|
||||
it("getPages with no filter returns all seeded pages", async () => {
|
||||
const list = await repo.getPages();
|
||||
expect(list).toHaveLength(2);
|
||||
for (const page of list) {
|
||||
expect(page.id).toBeDefined();
|
||||
expect(page.slug).toBeDefined();
|
||||
expect(["draft", "published"]).toContain(page.status);
|
||||
}
|
||||
});
|
||||
|
||||
it("getPages({ status: 'published' }) returns only published pages", async () => {
|
||||
const published = await repo.getPages({ status: "published" });
|
||||
expect(published).toHaveLength(1);
|
||||
for (const page of published) {
|
||||
expect(page.status).toBe("published");
|
||||
}
|
||||
});
|
||||
|
||||
it("getPages({ status: 'draft' }) returns only draft pages", async () => {
|
||||
const drafts = await repo.getPages({ status: "draft" });
|
||||
expect(drafts).toHaveLength(1);
|
||||
for (const page of drafts) {
|
||||
expect(page.status).toBe("draft");
|
||||
}
|
||||
});
|
||||
|
||||
describe("span emission (R50)", () => {
|
||||
it("getPageBySlug emits pages.getPageBySlug span with slug attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getPageBySlug("about");
|
||||
const span = tracer.findSpan("pages.getPageBySlug");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.slug).toBe("about");
|
||||
});
|
||||
|
||||
it("getPages emits pages.getPages span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getPages({ status: "published" });
|
||||
const span = tracer.findSpan("pages.getPages");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.status).toBe("published");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export const siteSettingsRepositoryContract =
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("span emission (R50)", () => {
|
||||
describe("span emission", () => {
|
||||
it("getSiteSettings emits site-settings.getSiteSettings span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("getPageBySlugUseCase", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("(R25) throws ZodError when repository returns malformed page data", async () => {
|
||||
it("throws ZodError when repository returns malformed page data", async () => {
|
||||
const repo = new MockPagesRepository([
|
||||
{
|
||||
id: "p-bad",
|
||||
@@ -34,6 +34,8 @@ describe("getPageBySlugUseCase", () => {
|
||||
} as never,
|
||||
]);
|
||||
const useCase = getPageBySlugUseCase(repo);
|
||||
await expect(useCase({ slug: "bad-page" })).rejects.toBeInstanceOf(ZodError);
|
||||
await expect(useCase({ slug: "bad-page" })).rejects.toBeInstanceOf(
|
||||
ZodError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("getSiteSettingsUseCase", () => {
|
||||
expect(result.siteName).toBe("My App");
|
||||
});
|
||||
|
||||
it("(R25) throws ZodError when repository returns malformed site settings", async () => {
|
||||
it("throws ZodError when repository returns malformed site settings", async () => {
|
||||
const malformedRepo = {
|
||||
getSiteSettings: async () => ({ siteName: "" }),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export type { MarketingPagesRouter } from "./integrations/api/router";
|
||||
export { PageNotFoundError } from "./entities/errors/page";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
|
||||
// Use case schemas + types (Plan 9 R18)
|
||||
// Use case schemas + types
|
||||
export {
|
||||
getPageBySlugInputSchema,
|
||||
getPageBySlugOutputSchema,
|
||||
@@ -26,4 +26,7 @@ export type { IGetSiteSettingsController } from "./interface-adapters/controller
|
||||
|
||||
// <gen:events>
|
||||
// <gen:realtime-channels>
|
||||
export { marketingPagesManifest, type MarketingPagesManifest } from "./feature.manifest";
|
||||
export {
|
||||
marketingPagesManifest,
|
||||
type MarketingPagesManifest,
|
||||
} from "./feature.manifest";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
|
||||
|
||||
// Mock repo also wraps in spans (R42).
|
||||
describe("MockPagesRepository emits spans (R42)", () => {
|
||||
// Mock repo also wraps in spans.
|
||||
describe("MockPagesRepository emits spans", () => {
|
||||
it("getPageBySlug emits one span with op='repository'", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
@@ -24,6 +27,8 @@ describe("MockPagesRepository emits spans (R42)", () => {
|
||||
await repo.getPages({ limit: 10 });
|
||||
expect(tracer.findSpan("pages.getPages")).toBeDefined();
|
||||
expect(tracer.findSpan("pages.getPages")!.attributes.limit).toBe(10);
|
||||
expect(tracer.findSpan("pages.getPages")!.attributes.count).toBeGreaterThanOrEqual(0);
|
||||
expect(
|
||||
tracer.findSpan("pages.getPages")!.attributes.count,
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/site-settings.repository.mock";
|
||||
|
||||
// Mock repo also wraps in spans (R42).
|
||||
describe("MockSiteSettingsRepository emits spans (R42)", () => {
|
||||
// Mock repo also wraps in spans.
|
||||
describe("MockSiteSettingsRepository emits spans", () => {
|
||||
it("getSiteSettings emits one span with op='repository'", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("marketingPagesRouter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("marketingPagesRouter (R26 error mapping)", () => {
|
||||
describe("marketingPagesRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
marketingPagesContainer.unbindAll();
|
||||
marketingPagesContainer.load(MarketingPagesModule);
|
||||
|
||||
@@ -3,94 +3,93 @@ import { defineContractSuite } from "@repo/core-testing/contract";
|
||||
import type { IMediaRepository } from "../application/repositories/media.repository.interface.js";
|
||||
import { mediaFactory } from "../__factories__/media.factory.js";
|
||||
|
||||
export const mediaRepositoryContract =
|
||||
defineContractSuite<IMediaRepository>(
|
||||
"IMediaRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IMediaRepository;
|
||||
export const mediaRepositoryContract = defineContractSuite<IMediaRepository>(
|
||||
"IMediaRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IMediaRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
mediaFactory.reset();
|
||||
repo = await buildSubject();
|
||||
beforeEach(async () => {
|
||||
mediaFactory.reset();
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
// --- getMedia ---
|
||||
|
||||
it("getMedia returns undefined for a missing id", async () => {
|
||||
const result = await repo.getMedia("does-not-exist");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- listMedia ---
|
||||
|
||||
it("listMedia returns empty array when no media exists", async () => {
|
||||
const result = await repo.listMedia();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// --- deleteMedia ---
|
||||
|
||||
it("deleteMedia removes the item so getMedia returns undefined", async () => {
|
||||
const seed = mediaFactory.build({ id: "contract-1" });
|
||||
// @ts-expect-error _store is a test helper on MockMediaRepository
|
||||
if (typeof repo._store === "function") {
|
||||
// @ts-expect-error _store is a test helper
|
||||
await repo._store(seed);
|
||||
}
|
||||
await repo.deleteMedia("contract-1");
|
||||
const result = await repo.getMedia("contract-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listMedia with limit returns at most limit items", async () => {
|
||||
// Only verifiable on mock; for real impl this checks the interface
|
||||
const result = await repo.listMedia({ limit: 0 });
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("listMedia with offset skips items", async () => {
|
||||
const result = await repo.listMedia({ offset: 100 });
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("getMedia returns undefined for empty string id", async () => {
|
||||
const result = await repo.getMedia("");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("span emission", () => {
|
||||
it("getMedia emits media.getMedia span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getMedia("nonexistent");
|
||||
const span = tracer.findSpan("media.getMedia");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
|
||||
// --- getMedia ---
|
||||
|
||||
it("getMedia returns undefined for a missing id", async () => {
|
||||
const result = await repo.getMedia("does-not-exist");
|
||||
expect(result).toBeUndefined();
|
||||
it("listMedia emits media.listMedia span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.listMedia({ limit: 10 });
|
||||
const span = tracer.findSpan("media.listMedia");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.limit).toBe(10);
|
||||
});
|
||||
|
||||
// --- listMedia ---
|
||||
|
||||
it("listMedia returns empty array when no media exists", async () => {
|
||||
const result = await repo.listMedia();
|
||||
expect(result).toHaveLength(0);
|
||||
it("deleteMedia emits media.deleteMedia span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.deleteMedia("nonexistent");
|
||||
const span = tracer.findSpan("media.deleteMedia");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
|
||||
// --- deleteMedia ---
|
||||
|
||||
it("deleteMedia removes the item so getMedia returns undefined", async () => {
|
||||
const seed = mediaFactory.build({ id: "contract-1" });
|
||||
// @ts-expect-error _store is a test helper on MockMediaRepository
|
||||
if (typeof repo._store === "function") {
|
||||
// @ts-expect-error _store is a test helper
|
||||
await repo._store(seed);
|
||||
}
|
||||
await repo.deleteMedia("contract-1");
|
||||
const result = await repo.getMedia("contract-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listMedia with limit returns at most limit items", async () => {
|
||||
// Only verifiable on mock; for real impl this checks the interface
|
||||
const result = await repo.listMedia({ limit: 0 });
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("listMedia with offset skips items", async () => {
|
||||
const result = await repo.listMedia({ offset: 100 });
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("getMedia returns undefined for empty string id", async () => {
|
||||
const result = await repo.getMedia("");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("span emission (R50)", () => {
|
||||
it("getMedia emits media.getMedia span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getMedia("nonexistent");
|
||||
const span = tracer.findSpan("media.getMedia");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
|
||||
it("listMedia emits media.listMedia span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.listMedia({ limit: 10 });
|
||||
const span = tracer.findSpan("media.listMedia");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.limit).toBe(10);
|
||||
});
|
||||
|
||||
it("deleteMedia emits media.deleteMedia span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.deleteMedia("nonexistent");
|
||||
const span = tracer.findSpan("media.deleteMedia");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { getMediaUseCase, getMediaOutputSchema } from "@/application/use-cases/get-media.use-case";
|
||||
import {
|
||||
getMediaUseCase,
|
||||
getMediaOutputSchema,
|
||||
} from "@/application/use-cases/get-media.use-case";
|
||||
import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
|
||||
import { MediaNotFoundError } from "@/entities/errors/media";
|
||||
import { mediaFactory } from "@/__factories__/media.factory";
|
||||
@@ -22,15 +25,17 @@ describe("getMediaUseCase", () => {
|
||||
const repo = new MockMediaRepository();
|
||||
const useCase = getMediaUseCase(repo);
|
||||
|
||||
await expect(useCase({ id: "missing" })).rejects.toThrow(MediaNotFoundError);
|
||||
await expect(useCase({ id: "missing" })).rejects.toThrow(
|
||||
MediaNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it("R25 — parses valid output without error", async () => {
|
||||
it("parses valid output without error", async () => {
|
||||
const validMedia = mediaFactory.build({ id: "m-r25" });
|
||||
expect(() => getMediaOutputSchema.parse(validMedia)).not.toThrow();
|
||||
});
|
||||
|
||||
it("R25 — throws ZodError when repository returns malformed media", async () => {
|
||||
it("throws ZodError when repository returns malformed media", async () => {
|
||||
const repo = new MockMediaRepository();
|
||||
// Store a malformed object (missing required fields) via type cast
|
||||
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { listMediaUseCase, listMediaOutputSchema } from "@/application/use-cases/list-media.use-case";
|
||||
import {
|
||||
listMediaUseCase,
|
||||
listMediaOutputSchema,
|
||||
} from "@/application/use-cases/list-media.use-case";
|
||||
import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
|
||||
import { mediaFactory } from "@/__factories__/media.factory";
|
||||
|
||||
@@ -33,12 +36,12 @@ describe("listMediaUseCase", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("R25 — parses valid output without error", async () => {
|
||||
it("parses valid output without error", async () => {
|
||||
const items = [mediaFactory.build(), mediaFactory.build()];
|
||||
expect(() => listMediaOutputSchema.parse(items)).not.toThrow();
|
||||
});
|
||||
|
||||
it("R25 — throws ZodError when repository returns malformed items", async () => {
|
||||
it("throws ZodError when repository returns malformed items", async () => {
|
||||
const repo = new MockMediaRepository();
|
||||
// Store a malformed object (missing required fields)
|
||||
await repo._store({ id: "bad", alt: "alt", url: "u" } as never);
|
||||
|
||||
@@ -40,8 +40,12 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
|
||||
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
mediaContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
mediaContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
mediaContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
mediaContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
|
||||
mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository);
|
||||
@@ -95,9 +99,15 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
|
||||
]) {
|
||||
if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym);
|
||||
}
|
||||
mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia);
|
||||
mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia);
|
||||
mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia);
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IGetMediaUseCase)
|
||||
.toConstantValue(wrappedGetMedia);
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IListMediaUseCase)
|
||||
.toConstantValue(wrappedListMedia);
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase)
|
||||
.toConstantValue(wrappedDeleteMedia);
|
||||
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IGetMediaController)
|
||||
@@ -138,8 +148,7 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -19,7 +19,8 @@ import { listMediaController } from "../interface-adapters/controllers/list-medi
|
||||
import { deleteMediaController } from "../interface-adapters/controllers/delete-media.controller";
|
||||
|
||||
export function bindProductionMedia(ctx: BindProductionContext): void {
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
||||
ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
@@ -28,17 +29,19 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
|
||||
if (mediaContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
mediaContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
mediaContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
mediaContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
mediaContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
mediaContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
// Real repository
|
||||
if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) {
|
||||
mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository);
|
||||
}
|
||||
const repo = new MediaRepository(config, tracer, logger);
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IMediaRepository)
|
||||
.toConstantValue(repo);
|
||||
mediaContainer.bind(MEDIA_SYMBOLS.IMediaRepository).toConstantValue(repo);
|
||||
|
||||
// Use cases — wrapped with span + capture at bind time
|
||||
const wrappedGetMedia = withSpan(
|
||||
@@ -76,9 +79,15 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
|
||||
]) {
|
||||
if (mediaContainer.isBound(sym)) mediaContainer.unbind(sym);
|
||||
}
|
||||
mediaContainer.bind(MEDIA_SYMBOLS.IGetMediaUseCase).toConstantValue(wrappedGetMedia);
|
||||
mediaContainer.bind(MEDIA_SYMBOLS.IListMediaUseCase).toConstantValue(wrappedListMedia);
|
||||
mediaContainer.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toConstantValue(wrappedDeleteMedia);
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IGetMediaUseCase)
|
||||
.toConstantValue(wrappedGetMedia);
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IListMediaUseCase)
|
||||
.toConstantValue(wrappedListMedia);
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IDeleteMediaUseCase)
|
||||
.toConstantValue(wrappedDeleteMedia);
|
||||
|
||||
// Controllers — wrapped with span at bind time
|
||||
for (const sym of [
|
||||
@@ -127,8 +136,7 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -3,7 +3,7 @@ export { MediaNotFoundError } from "./entities/errors/media";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
export type { MediaRouter } from "./integrations/api/router";
|
||||
|
||||
// Use case schemas + types (Plan 9 R18)
|
||||
// Use case schemas + types
|
||||
export {
|
||||
getMediaInputSchema,
|
||||
getMediaOutputSchema,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock";
|
||||
import type { Media } from "@/entities/models/media";
|
||||
|
||||
@@ -12,8 +15,8 @@ const SAMPLE_MEDIA: Media = {
|
||||
filesize: 1024,
|
||||
};
|
||||
|
||||
// Mock repo also wraps in spans (R42).
|
||||
describe("MockMediaRepository emits spans (R42)", () => {
|
||||
// Mock repo also wraps in spans.
|
||||
describe("MockMediaRepository emits spans", () => {
|
||||
it("getMedia emits one span with op='repository' and found attribute", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("mediaRouter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRouter (R26 error mapping)", () => {
|
||||
describe("mediaRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
mediaContainer.unbindAll();
|
||||
mediaContainer.load(MediaModule);
|
||||
@@ -123,7 +123,9 @@ describe("mediaRouter (R26 error mapping)", () => {
|
||||
mediaContainer
|
||||
.bind(MEDIA_SYMBOLS.IDeleteMediaController)
|
||||
.toDynamicValue((ctx) =>
|
||||
deleteMediaController(ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase)),
|
||||
deleteMediaController(
|
||||
ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
const caller = mediaRouter.createCaller({});
|
||||
|
||||
@@ -22,66 +22,65 @@ export const CONTRACT_HEADER_SEED: Header = {
|
||||
* Header is a singleton (Payload Global). The interface exposes only
|
||||
* getHeader(). The contract verifies the shape, count, and order of items.
|
||||
*/
|
||||
export const headerRepositoryContract =
|
||||
defineContractSuite<IHeaderRepository>(
|
||||
"IHeaderRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IHeaderRepository;
|
||||
export const headerRepositoryContract = defineContractSuite<IHeaderRepository>(
|
||||
"IHeaderRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IHeaderRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repo = await buildSubject();
|
||||
beforeEach(async () => {
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
// --- getHeader ---
|
||||
|
||||
it("getHeader returns an object with an items array of the seeded length", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(header).toBeDefined();
|
||||
expect(header.items).toBeInstanceOf(Array);
|
||||
expect(header.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("getHeader items appear in the seeded order with correct shape", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(header.items[0]?.label).toBe("Home");
|
||||
expect(header.items[0]?.href).toBe("/");
|
||||
expect(header.items[0]?.external).toBe(false);
|
||||
expect(header.items[1]?.label).toBe("Blog");
|
||||
expect(header.items[1]?.href).toBe("/blog");
|
||||
expect(header.items[1]?.external).toBe(false);
|
||||
expect(header.items[2]?.label).toBe("Docs");
|
||||
expect(header.items[2]?.href).toBe("/docs");
|
||||
expect(header.items[2]?.external).toBe(true);
|
||||
});
|
||||
|
||||
it("getHeader items have label, href, and external fields", async () => {
|
||||
const header = await repo.getHeader();
|
||||
for (const item of header.items) {
|
||||
expect(typeof item.label).toBe("string");
|
||||
expect(item.label.length).toBeGreaterThan(0);
|
||||
expect(typeof item.href).toBe("string");
|
||||
expect(item.href.length).toBeGreaterThan(0);
|
||||
expect(typeof item.external).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("getHeader logoId is string or undefined", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(
|
||||
header.logoId === undefined || typeof header.logoId === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("span emission", () => {
|
||||
it("getHeader emits header.getHeader span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getHeader();
|
||||
const span = tracer.findSpan("header.getHeader");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
});
|
||||
|
||||
// --- getHeader ---
|
||||
|
||||
it("getHeader returns an object with an items array of the seeded length", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(header).toBeDefined();
|
||||
expect(header.items).toBeInstanceOf(Array);
|
||||
expect(header.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("getHeader items appear in the seeded order with correct shape", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(header.items[0]?.label).toBe("Home");
|
||||
expect(header.items[0]?.href).toBe("/");
|
||||
expect(header.items[0]?.external).toBe(false);
|
||||
expect(header.items[1]?.label).toBe("Blog");
|
||||
expect(header.items[1]?.href).toBe("/blog");
|
||||
expect(header.items[1]?.external).toBe(false);
|
||||
expect(header.items[2]?.label).toBe("Docs");
|
||||
expect(header.items[2]?.href).toBe("/docs");
|
||||
expect(header.items[2]?.external).toBe(true);
|
||||
});
|
||||
|
||||
it("getHeader items have label, href, and external fields", async () => {
|
||||
const header = await repo.getHeader();
|
||||
for (const item of header.items) {
|
||||
expect(typeof item.label).toBe("string");
|
||||
expect(item.label.length).toBeGreaterThan(0);
|
||||
expect(typeof item.href).toBe("string");
|
||||
expect(item.href.length).toBeGreaterThan(0);
|
||||
expect(typeof item.external).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("getHeader logoId is string or undefined", async () => {
|
||||
const header = await repo.getHeader();
|
||||
expect(
|
||||
header.logoId === undefined || typeof header.logoId === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("span emission (R50)", () => {
|
||||
it("getHeader emits header.getHeader span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getHeader();
|
||||
const span = tracer.findSpan("header.getHeader");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("getHeaderUseCase", () => {
|
||||
expect(result.items[0]?.label).toBe("Home");
|
||||
});
|
||||
|
||||
it("(R25) throws ZodError when repository returns malformed header", async () => {
|
||||
it("throws ZodError when repository returns malformed header", async () => {
|
||||
const malformedRepo = {
|
||||
getHeader: async () =>
|
||||
({ items: [{ label: "", href: "/", external: false }] }) as never,
|
||||
|
||||
@@ -37,8 +37,12 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
|
||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
navigationContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
navigationContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
navigationContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||
@@ -54,7 +58,11 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
|
||||
{ name: "navigation.getHeader", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "navigation", layer: "use-case", name: "navigation.getHeader" },
|
||||
{
|
||||
feature: "navigation",
|
||||
layer: "use-case",
|
||||
name: "navigation.getHeader",
|
||||
},
|
||||
getHeaderUseCase(repo),
|
||||
),
|
||||
);
|
||||
@@ -77,13 +85,16 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
|
||||
{ name: "navigation.getHeader", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "navigation", layer: "controller", name: "navigation.getHeader" },
|
||||
{
|
||||
feature: "navigation",
|
||||
layer: "controller",
|
||||
name: "navigation.getHeader",
|
||||
},
|
||||
getHeaderController(wrappedGetHeader),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -15,7 +15,8 @@ import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
|
||||
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
|
||||
|
||||
export function bindProductionNavigation(ctx: BindProductionContext): void {
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
||||
ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
@@ -24,8 +25,12 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
|
||||
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
navigationContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
|
||||
navigationContainer.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
|
||||
navigationContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
navigationContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
// Real repository
|
||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
||||
@@ -42,7 +47,11 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
|
||||
{ name: "navigation.getHeader", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "navigation", layer: "use-case", name: "navigation.getHeader" },
|
||||
{
|
||||
feature: "navigation",
|
||||
layer: "use-case",
|
||||
name: "navigation.getHeader",
|
||||
},
|
||||
getHeaderUseCase(repo),
|
||||
),
|
||||
);
|
||||
@@ -66,13 +75,16 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
|
||||
{ name: "navigation.getHeader", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "navigation", layer: "controller", name: "navigation.getHeader" },
|
||||
{
|
||||
feature: "navigation",
|
||||
layer: "controller",
|
||||
name: "navigation.getHeader",
|
||||
},
|
||||
getHeaderController(wrappedGetHeader),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are accept-and-forward in Phase 6; consumed by Phase 7 generator
|
||||
// output at the <gen:event-handlers> / <gen:jobs> anchors below.
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
|
||||
@@ -3,7 +3,7 @@ export type { NavigationRouter } from "./integrations/api/router";
|
||||
export { HeaderNotFoundError } from "./entities/errors/header";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
|
||||
// Use case schemas + types (Plan 9 R18)
|
||||
// Use case schemas + types
|
||||
export {
|
||||
getHeaderInputSchema,
|
||||
getHeaderOutputSchema,
|
||||
@@ -17,4 +17,7 @@ export type { IGetHeaderController } from "./interface-adapters/controllers/get-
|
||||
|
||||
// <gen:events>
|
||||
// <gen:realtime-channels>
|
||||
export { navigationManifest, type NavigationManifest } from "./feature.manifest";
|
||||
export {
|
||||
navigationManifest,
|
||||
type NavigationManifest,
|
||||
} from "./feature.manifest";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||
|
||||
// Mock repo also wraps in spans (R42).
|
||||
describe("MockHeaderRepository emits spans (R42)", () => {
|
||||
// Mock repo also wraps in spans.
|
||||
describe("MockHeaderRepository emits spans", () => {
|
||||
it("getHeader emits one span with op='repository'", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("navigationRouter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigationRouter (R26 error mapping)", () => {
|
||||
describe("navigationRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
navigationContainer.unbindAll();
|
||||
navigationContainer.load(NavigationModule);
|
||||
@@ -43,7 +43,10 @@ describe("navigationRouter (R26 error mapping)", () => {
|
||||
it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => {
|
||||
const caller = navigationRouter.createCaller({});
|
||||
try {
|
||||
await caller.header({ unexpected: "field" } as unknown as Record<string, never>);
|
||||
await caller.header({ unexpected: "field" } as unknown as Record<
|
||||
string,
|
||||
never
|
||||
>);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
@@ -66,12 +69,16 @@ describe("navigationRouter (R26 error mapping)", () => {
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase)
|
||||
.toDynamicValue((ctx) =>
|
||||
getHeaderUseCase(ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository)),
|
||||
getHeaderUseCase(
|
||||
ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository),
|
||||
),
|
||||
);
|
||||
navigationContainer
|
||||
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
|
||||
.toDynamicValue((ctx) =>
|
||||
getHeaderController(ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase)),
|
||||
getHeaderController(
|
||||
ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
const caller = navigationRouter.createCaller({});
|
||||
|
||||
Reference in New Issue
Block a user