Initial commit
This commit is contained in:
162
packages/auth/AGENTS.md
Normal file
162
packages/auth/AGENTS.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# AGENTS.md — auth
|
||||
|
||||
Users collection + authentication use cases (sign-in, sign-up, sign-out). Provides the Users Payload collection, AuthenticationService, and tRPC procedures for authentication workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
`@repo/auth` owns: User/Session/Cookie domain models, auth-scoped errors, the `IUsersRepository` + `IAuthenticationService` interfaces, three use cases, three controllers, a real Payload-backed repository + service, and the tRPC `authRouter`. All procedures are mutations — there are no query builders.
|
||||
|
||||
## Layer responsibilities
|
||||
|
||||
| Layer | Key files |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| **entities/models** | `user.ts`, `session.ts`, `cookie.ts` — Zod schemas + inferred types |
|
||||
| **entities/errors** | `auth.ts` (AuthenticationError, UnauthenticatedError, UnauthorizedError), `common.ts` (InputParseError) |
|
||||
| **application/use-cases** | `sign-in.use-case.ts`, `sign-up.use-case.ts`, `sign-out.use-case.ts` — factory functions + exported schemas |
|
||||
| **application/repositories** | `users.repository.interface.ts` — `IUsersRepository` |
|
||||
| **application/services** | `authentication.service.interface.ts` — `IAuthenticationService` |
|
||||
| **infrastructure/repositories** | `users.repository.ts` (real Payload-backed), `users.repository.mock.ts` (in-memory) |
|
||||
| **infrastructure/services** | `authentication.service.ts` (real Payload-backed), `authentication.service.mock.ts` (in-memory) |
|
||||
| **interface-adapters/controllers** | `sign-in.controller.ts`, `sign-up.controller.ts`, `sign-out.controller.ts` — one file per use case |
|
||||
| **di** | `symbols.ts` (AUTH_SYMBOLS), `module.ts`, `container.ts`, `bind-production.ts` |
|
||||
| **integrations/api** | `procedures.ts` (authProcedure), `router.ts` (authRouter) |
|
||||
| **integrations/cms** | `collections/users.ts` — Payload Users CollectionConfig |
|
||||
| **ui** | `src/ui/index.ts` — placeholder (auth is mutations only; no query builders today) |
|
||||
|
||||
## Public exports
|
||||
|
||||
| Subpath | Contents |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `.` | `User`, `Session`, `Cookie` types; `AuthenticationError`, `UnauthenticatedError`, `UnauthorizedError`, `InputParseError`; `SESSION_COOKIE`; all use-case schemas + input/output types + `IXUseCase` aliases; `IXController` type aliases; `AuthRouter` type |
|
||||
| `./ui` | Placeholder — extend here when auth gains React Query builders, never re-add to root |
|
||||
| `./api` | `authRouter` (tRPC router) |
|
||||
| `./cms` | Payload Users collection definition |
|
||||
| `./di/bind-production` | `bindProductionAuth(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedAuth(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
| `./di/container` | `authContainer` — the per-feature inversify container (consumed by e2e tests + production Payload event-tasks) |
|
||||
| `./di/symbols` | `AUTH_SYMBOLS` — DI symbol registry (consumed by e2e tests + production Payload event-tasks) |
|
||||
|
||||
## Use-case + controller patterns
|
||||
|
||||
See `CLAUDE.md` Key Conventions and `docs/architecture/overview.md` for the canonical factory templates.
|
||||
|
||||
### Use cases
|
||||
|
||||
| Use case | Input schema | Output schema | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------------- |
|
||||
| `signInUseCase` | `signInInputSchema` — `{ username, password }` | `signInOutputSchema` — `{ session, cookie }` | Throws `AuthenticationError` on bad credentials |
|
||||
| `signUpUseCase` | `signUpInputSchema` — `{ username, password, confirmPassword }` with `.refine` | `signUpOutputSchema` — `{ session, cookie }` | Throws `AuthenticationError` on taken username |
|
||||
| `signOutUseCase` | `signOutInputSchema` — `{ sessionId }` | void (no `xOutputSchema`) | Calls `authenticationService.invalidateSession` |
|
||||
|
||||
### Controllers
|
||||
|
||||
| Controller | Presenter | Return type |
|
||||
| ------------------- | ------------------------------------------- | --------------------------------------- |
|
||||
| `signInController` | `presenter(value) { return value.cookie; }` | `ReturnType<typeof presenter>` (Cookie) |
|
||||
| `signUpController` | `presenter(value) { return value.cookie; }` | `ReturnType<typeof presenter>` (Cookie) |
|
||||
| `signOutController` | none (void) | `Promise<void>` |
|
||||
|
||||
Controllers accept `unknown` input and `safeParse` with the use-case's `xInputSchema`, throwing `InputParseError` on failure.
|
||||
|
||||
## Real Payload implementations
|
||||
|
||||
- `UsersRepository` (`infrastructure/repositories/users.repository.ts`) — calls `getPayload({ config })` for `getUser`, `getUserByUsername`, and `createUser`. Receives `SanitizedConfig` at constructor time.
|
||||
- `AuthenticationService` (`infrastructure/services/authentication.service.ts`) — implements `hashPassword` and `verifyPassword` with Node.js `crypto` (pbkdf2). Three session-related methods (`createSession`, `validateSession`, `invalidateSession`) are **deferred** — they throw `NotImplementedError`. The mock (`authentication.service.mock.ts`) handles all test paths.
|
||||
|
||||
## Errors → tRPC codes
|
||||
|
||||
| Error class | tRPC code | Thrown by |
|
||||
| ---------------------- | -------------- | ------------------------------- |
|
||||
| `InputParseError` | `BAD_REQUEST` | controllers (safeParse failure) |
|
||||
| `AuthenticationError` | `UNAUTHORIZED` | sign-in / sign-up use cases |
|
||||
| `UnauthenticatedError` | `UNAUTHORIZED` | future session-guard middleware |
|
||||
| `UnauthorizedError` | `FORBIDDEN` | future authorization checks |
|
||||
|
||||
Defined in `src/integrations/api/procedures.ts` via `authProcedure = t.procedure.use(defineErrorMiddleware([...]))`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Factories:** `src/__factories__/user.factory.ts`, `src/__factories__/session.factory.ts`
|
||||
- **Contract suite:** `src/__contracts__/users-repository.contract.ts` — runs against mock and real `UsersRepository`
|
||||
- **Unit tests:** colocated `*.test.ts` next to each source file
|
||||
- **Feature integration:** `tests/sign-in-flow.feature.test.ts` — full slice: tRPC caller → controller → use case → mock repo/service
|
||||
- **R25** (output validation): `sign-in.use-case.test.ts` and `sign-up.use-case.test.ts` each have a test that injects a malformed service mock and asserts `.rejects.toBeInstanceOf(ZodError)`. `signOut` is void — no R25.
|
||||
- **R26** (router error mapping): `router.test.ts` has `UNAUTHORIZED` on bad credentials and `BAD_REQUEST` on schema failure.
|
||||
- **R27/R28** (presenter shape): sign-in and sign-up controller tests assert `result.name`, `result.value`, etc. (Cookie shape), not the full `{ session, cookie }` use-case output.
|
||||
|
||||
```bash
|
||||
pnpm test --filter @repo/auth
|
||||
pnpm test --filter @repo/auth -- --watch
|
||||
```
|
||||
|
||||
See `docs/guides/tdd-workflow.md` for the full cycle.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
src/
|
||||
entities/
|
||||
models/
|
||||
user.ts
|
||||
session.ts
|
||||
cookie.ts
|
||||
errors/
|
||||
auth.ts # AuthenticationError, UnauthenticatedError, UnauthorizedError
|
||||
common.ts # InputParseError
|
||||
application/
|
||||
repositories/
|
||||
users.repository.interface.ts
|
||||
services/
|
||||
authentication.service.interface.ts
|
||||
use-cases/
|
||||
sign-in.use-case.ts
|
||||
sign-up.use-case.ts
|
||||
sign-out.use-case.ts
|
||||
infrastructure/
|
||||
repositories/
|
||||
users.repository.ts # real Payload-backed
|
||||
users.repository.mock.ts
|
||||
services/
|
||||
authentication.service.ts # real (session methods deferred)
|
||||
authentication.service.mock.ts
|
||||
interface-adapters/
|
||||
controllers/
|
||||
sign-in.controller.ts
|
||||
sign-up.controller.ts
|
||||
sign-out.controller.ts
|
||||
integrations/
|
||||
api/
|
||||
procedures.ts # authProcedure
|
||||
router.ts # authRouter
|
||||
cms/
|
||||
collections/
|
||||
users.ts
|
||||
index.ts
|
||||
di/
|
||||
symbols.ts # AUTH_SYMBOLS
|
||||
module.ts
|
||||
container.ts
|
||||
bind-production.ts
|
||||
ui/
|
||||
index.ts # placeholder
|
||||
index.ts
|
||||
__factories__/
|
||||
user.factory.ts
|
||||
session.factory.ts
|
||||
__contracts__/
|
||||
users-repository.contract.ts
|
||||
tests/
|
||||
sign-in-flow.feature.test.ts
|
||||
```
|
||||
|
||||
## What it must NOT import
|
||||
|
||||
- Any other feature package (`@repo/blog`, `@repo/media`, etc.)
|
||||
- Any app package
|
||||
- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui` directly; only `@repo/core-shared`
|
||||
> Note: `@repo/core-trpc` and `@repo/core-ui` are optional packages scaffolded via `pnpm turbo gen core-package trpc` / `ui`. If not present, these constraints still apply to any future installation.
|
||||
|
||||
## Cross-links
|
||||
|
||||
- ADR-012 (`docs/decisions/adr-012-feature-conventions.md`) — factory-style use cases, per-use-case controllers, file-naming conventions
|
||||
- ADR-013 (`docs/decisions/adr-013-input-output-unification.md`) — schemas-in-use-case, presenter, `./ui` subpath, error middleware
|
||||
11
packages/auth/CHANGELOG.md
Normal file
11
packages/auth/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Changelog — @repo/auth
|
||||
|
||||
All notable changes to the `auth` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md).
|
||||
|
||||
## 0.1.0 (2026-05-13)
|
||||
|
||||
### Initial baseline
|
||||
|
||||
The `auth` feature established at v0.1.0 alongside the hybrid versioning rollout (ADR-021). The feature has been stable since the template-reset cleanup; this is the first formally versioned baseline.
|
||||
|
||||
Future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/auth/**` since the last release.
|
||||
3
packages/auth/eslint.config.js
Normal file
3
packages/auth/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
38
packages/auth/package.json
Normal file
38
packages/auth/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@repo/auth",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cms": "./src/integrations/cms/index.ts",
|
||||
"./api": "./src/integrations/api/router.ts",
|
||||
"./di/bind-production": "./src/di/bind-production.ts",
|
||||
"./di/bind-dev-seed": "./src/di/bind-dev-seed.ts",
|
||||
"./di/container": "./src/di/container.ts",
|
||||
"./di/symbols": "./src/di/symbols.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"inversify": "^6.2.0",
|
||||
"payload": "^3.14.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
88
packages/auth/src/__contracts__/users-repository.contract.ts
Normal file
88
packages/auth/src/__contracts__/users-repository.contract.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { it, expect, beforeEach, describe } from "vitest";
|
||||
import { defineContractSuite } from "@repo/core-testing/contract";
|
||||
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
|
||||
import { userFactory } from "../__factories__/user.factory";
|
||||
|
||||
export const usersRepositoryContract = defineContractSuite<IUsersRepository>(
|
||||
"IUsersRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IUsersRepository;
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
2
packages/auth/src/__factories__/index.ts
Normal file
2
packages/auth/src/__factories__/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { userFactory } from "./user.factory";
|
||||
export { sessionFactory } from "./session.factory";
|
||||
26
packages/auth/src/__factories__/session.factory.test.ts
Normal file
26
packages/auth/src/__factories__/session.factory.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { sessionFactory } from "@/__factories__/session.factory";
|
||||
|
||||
describe("sessionFactory", () => {
|
||||
beforeEach(() => sessionFactory.reset());
|
||||
|
||||
it("returns a Session with stable defaults", () => {
|
||||
const s = sessionFactory.build();
|
||||
expect(s.id).toBe("session-1");
|
||||
expect(s.userId).toBe("user-1");
|
||||
expect(s.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("applies overrides", () => {
|
||||
const s = sessionFactory.build({ userId: "user-42" });
|
||||
expect(s.userId).toBe("user-42");
|
||||
expect(s.id).toBe("session-1");
|
||||
});
|
||||
|
||||
it("increments sequence per build", () => {
|
||||
const a = sessionFactory.build();
|
||||
const b = sessionFactory.build();
|
||||
expect(a.id).toBe("session-1");
|
||||
expect(b.id).toBe("session-2");
|
||||
});
|
||||
});
|
||||
8
packages/auth/src/__factories__/session.factory.ts
Normal file
8
packages/auth/src/__factories__/session.factory.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineFactory } from "@repo/core-testing/factory";
|
||||
import type { Session } from "../entities/models/session";
|
||||
|
||||
export const sessionFactory = defineFactory<Session>(({ sequence }) => ({
|
||||
id: `session-${sequence}`,
|
||||
userId: "user-1",
|
||||
expiresAt: new Date("2026-12-31T23:59:59Z"),
|
||||
}));
|
||||
26
packages/auth/src/__factories__/user.factory.test.ts
Normal file
26
packages/auth/src/__factories__/user.factory.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("userFactory", () => {
|
||||
beforeEach(() => userFactory.reset());
|
||||
|
||||
it("returns a User with stable defaults", () => {
|
||||
const u = userFactory.build();
|
||||
expect(u.id).toBe("user-1");
|
||||
expect(u.username).toBe("user1");
|
||||
expect(u.passwordHash).toHaveLength(60);
|
||||
});
|
||||
|
||||
it("applies overrides", () => {
|
||||
const u = userFactory.build({ username: "alice" });
|
||||
expect(u.username).toBe("alice");
|
||||
expect(u.id).toBe("user-1");
|
||||
});
|
||||
|
||||
it("increments sequence per build", () => {
|
||||
const a = userFactory.build();
|
||||
const b = userFactory.build();
|
||||
expect(a.id).toBe("user-1");
|
||||
expect(b.id).toBe("user-2");
|
||||
});
|
||||
});
|
||||
8
packages/auth/src/__factories__/user.factory.ts
Normal file
8
packages/auth/src/__factories__/user.factory.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineFactory } from "@repo/core-testing/factory";
|
||||
import type { User } from "../entities/models/user";
|
||||
|
||||
export const userFactory = defineFactory<User>(({ sequence }) => ({
|
||||
id: `user-${sequence}`,
|
||||
username: `user${sequence}`,
|
||||
passwordHash: `$2b$10$stablehashfortest${sequence}`.padEnd(60, "x"),
|
||||
}));
|
||||
27
packages/auth/src/__seeds__/dev.ts
Normal file
27
packages/auth/src/__seeds__/dev.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { userFactory } from "../__factories__/user.factory";
|
||||
import type { User } from "../entities/models/user";
|
||||
|
||||
/**
|
||||
* Realistic auth seed for dev mode + storybook stories.
|
||||
*
|
||||
* Built from `userFactory` so factory defaults take care of boring fields
|
||||
* and we only override what makes the data look like a real user database.
|
||||
*
|
||||
* Lazily produced so importing this module is side-effect-free — the
|
||||
* factory's sequence counter only advances when a binder calls
|
||||
* `buildDevUsers()`.
|
||||
*/
|
||||
export function buildDevUsers(): User[] {
|
||||
return [
|
||||
userFactory.build({
|
||||
id: "alice",
|
||||
username: "alice",
|
||||
passwordHash: "hashed_secret_alice",
|
||||
}),
|
||||
userFactory.build({
|
||||
id: "bob",
|
||||
username: "bob",
|
||||
passwordHash: "hashed_secret_bob",
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { User } from "../../entities/models/user";
|
||||
|
||||
export interface IUsersRepository {
|
||||
getUser(id: string): Promise<User | undefined>;
|
||||
getUserByUsername(username: string): Promise<User | undefined>;
|
||||
createUser(input: User): Promise<User>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import type { Session } from "../../entities/models/session";
|
||||
import type { User } from "../../entities/models/user";
|
||||
|
||||
export interface IAuthenticationService {
|
||||
generateUserId(): string;
|
||||
hashPassword(password: string): Promise<string>;
|
||||
verifyPassword(hash: string, password: string): Promise<boolean>;
|
||||
validateSession(
|
||||
sessionId: string,
|
||||
): Promise<{ user: User; session: Session }>;
|
||||
createSession(user: User): Promise<{ session: Session; cookie: Cookie }>;
|
||||
invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>;
|
||||
}
|
||||
179
packages/auth/src/application/use-cases/sign-in.use-case.test.ts
Normal file
179
packages/auth/src/application/use-cases/sign-in.use-case.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
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,
|
||||
TooManyRequestsError,
|
||||
} from "@/entities/errors/auth";
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
import { NoopRateLimit, InMemoryRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import { RecordingRateLimit } from "@repo/core-testing/rate-limit";
|
||||
|
||||
describe("signInUseCase", () => {
|
||||
it("returns a session + cookie on valid credentials", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const seedUser = userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_testpassword",
|
||||
});
|
||||
await users.createUser(seedUser);
|
||||
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
const result = await useCase({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
});
|
||||
|
||||
expect(result.session.userId).toBe(seedUser.id);
|
||||
expect(result.cookie.name).toBe("session");
|
||||
});
|
||||
|
||||
it("throws AuthenticationError when user does not exist", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
|
||||
await expect(
|
||||
useCase({ username: "ghost", password: "anything" }),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it("throws AuthenticationError on wrong password", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
await users.createUser(
|
||||
userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_correctpassword",
|
||||
}),
|
||||
);
|
||||
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
await expect(
|
||||
useCase({ username: "alice", password: "wrong" }),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it("captures both ip and account consume calls via RecordingRateLimit", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const rl = new RecordingRateLimit();
|
||||
const seedUser = userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_testpassword",
|
||||
});
|
||||
await users.createUser(seedUser);
|
||||
|
||||
const useCase = signInUseCase(users, auth, rl);
|
||||
await useCase({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
clientIp: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(rl.consumeCalls).toHaveLength(2);
|
||||
expect(rl.consumeCalls[0]).toMatchObject({
|
||||
budgetName: "ip",
|
||||
key: "signIn:ip:1.2.3.4",
|
||||
});
|
||||
expect(rl.consumeCalls[1]).toMatchObject({
|
||||
budgetName: "account",
|
||||
key: "signIn:account:alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws TooManyRequestsError when ip budget is exhausted", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const rl = new InMemoryRateLimit([
|
||||
{ name: "ip", window: "1m", budget: 1 },
|
||||
{ name: "account", window: "1h", budget: 10 },
|
||||
]);
|
||||
const seedUser = userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_testpassword",
|
||||
});
|
||||
await users.createUser(seedUser);
|
||||
|
||||
const useCase = signInUseCase(users, auth, rl);
|
||||
await useCase({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
clientIp: "1.2.3.4",
|
||||
});
|
||||
|
||||
await expect(
|
||||
useCase({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
clientIp: "1.2.3.4",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TooManyRequestsError);
|
||||
});
|
||||
|
||||
it("throws TooManyRequestsError when account budget is exhausted", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const rl = new InMemoryRateLimit([
|
||||
{ name: "ip", window: "1m", budget: 100 },
|
||||
{ name: "account", window: "1h", budget: 1 },
|
||||
]);
|
||||
const seedUser = userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_testpassword",
|
||||
});
|
||||
await users.createUser(seedUser);
|
||||
|
||||
const useCase = signInUseCase(users, auth, rl);
|
||||
// First call succeeds (ip allows, account allows, credentials ok)
|
||||
await useCase({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
clientIp: "1.2.3.4",
|
||||
});
|
||||
|
||||
// Second call: ip still allows (high budget), account is exhausted
|
||||
await expect(
|
||||
useCase({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
clientIp: "5.6.7.8",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TooManyRequestsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signInUseCase output validation", () => {
|
||||
it("throws when authenticationService returns a malformed session", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const seed = userFactory.build({ username: "alice" });
|
||||
await users.createUser(seed);
|
||||
|
||||
const auth = {
|
||||
verifyPassword: async () => true,
|
||||
// session missing required fields → should fail signInOutputSchema.parse
|
||||
createSession: async () => ({ session: { id: 123 }, cookie: null }),
|
||||
} as unknown as IAuthenticationService;
|
||||
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
await expect(
|
||||
useCase({ username: "alice", password: "x" }),
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors the success shape", () => {
|
||||
expect(signInOutputSchema).toBeDefined();
|
||||
const parsed = signInOutputSchema.safeParse({
|
||||
session: { id: "s1", userId: "u1", expiresAt: new Date() },
|
||||
cookie: { name: "session", value: "s1", attributes: {} },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
68
packages/auth/src/application/use-cases/sign-in.use-case.ts
Normal file
68
packages/auth/src/application/use-cases/sign-in.use-case.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { IRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import {
|
||||
AuthenticationError,
|
||||
TooManyRequestsError,
|
||||
} from "../../entities/errors/auth";
|
||||
import { cookieSchema } from "../../entities/models/cookie";
|
||||
import { sessionSchema } from "../../entities/models/session";
|
||||
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const signInInputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
clientIp: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type SignInInput = z.infer<typeof signInInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const signInOutputSchema = z.object({
|
||||
session: sessionSchema,
|
||||
cookie: cookieSchema,
|
||||
});
|
||||
export type SignInOutput = z.infer<typeof signInOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
|
||||
|
||||
export const signInUseCase =
|
||||
(
|
||||
usersRepository: IUsersRepository,
|
||||
authenticationService: IAuthenticationService,
|
||||
rateLimit: IRateLimit,
|
||||
) =>
|
||||
async (input: SignInInput): Promise<SignInOutput> => {
|
||||
const { allowed: ipAllowed } = await rateLimit.consume(
|
||||
"ip",
|
||||
`signIn:ip:${input.clientIp ?? ""}`,
|
||||
);
|
||||
if (!ipAllowed) throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
|
||||
const { allowed: accountAllowed } = await rateLimit.consume(
|
||||
"account",
|
||||
`signIn:account:${input.username}`,
|
||||
);
|
||||
if (!accountAllowed)
|
||||
throw new TooManyRequestsError("Too many sign-in attempts");
|
||||
|
||||
const existingUser = await usersRepository.getUserByUsername(
|
||||
input.username,
|
||||
);
|
||||
if (!existingUser) {
|
||||
throw new AuthenticationError("User does not exist");
|
||||
}
|
||||
const validPassword = await authenticationService.verifyPassword(
|
||||
existingUser.passwordHash,
|
||||
input.password,
|
||||
);
|
||||
if (!validPassword) {
|
||||
throw new AuthenticationError("Incorrect username or password");
|
||||
}
|
||||
const result = await authenticationService.createSession(existingUser);
|
||||
return signInOutputSchema.parse(result);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
|
||||
describe("signOutUseCase", () => {
|
||||
it("returns void on successful sign-out", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signOutUseCase(auth);
|
||||
|
||||
const result = await useCase({ sessionId: "session_1" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
17
packages/auth/src/application/use-cases/sign-out.use-case.ts
Normal file
17
packages/auth/src/application/use-cases/sign-out.use-case.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const signOutInputSchema = z.object({ sessionId: z.string() }).strict();
|
||||
export type SignOutInput = z.infer<typeof signOutInputSchema>;
|
||||
|
||||
// No xOutputSchema — use case returns void.
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;
|
||||
|
||||
export const signOutUseCase =
|
||||
(authenticationService: IAuthenticationService) =>
|
||||
async (input: SignOutInput): Promise<void> => {
|
||||
await authenticationService.invalidateSession(input.sessionId);
|
||||
};
|
||||
238
packages/auth/src/application/use-cases/sign-up.use-case.test.ts
Normal file
238
packages/auth/src/application/use-cases/sign-up.use-case.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
RecordingEventBus,
|
||||
RecordingConsent,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
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";
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("signUpUseCase", () => {
|
||||
it("creates a new user and returns session + cookie", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
const useCase = signUpUseCase(users, auth, bus, undefined);
|
||||
|
||||
const result = await useCase({
|
||||
username: "carol",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
});
|
||||
|
||||
expect(result.session.userId).toBeTruthy();
|
||||
expect(result.cookie.name).toBe("session");
|
||||
});
|
||||
|
||||
it("throws AuthenticationError when username taken", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
await users.createUser(userFactory.build({ username: "alice" }));
|
||||
|
||||
const useCase = signUpUseCase(users, auth, bus, undefined);
|
||||
await expect(
|
||||
useCase({
|
||||
username: "alice",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it("publishes auth.user.signed-up after creating the user", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
const useCase = signUpUseCase(users, auth, bus, undefined);
|
||||
|
||||
await useCase({
|
||||
username: "dave",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
});
|
||||
|
||||
expect(bus.published).toHaveLength(1);
|
||||
const published = bus.published[0]!;
|
||||
expect(published.name).toBe("auth.user.signed-up");
|
||||
expect(published.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: expect.any(String),
|
||||
email: expect.stringMatching(/^dave@/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("works without an event bus (welcome email skipped silently)", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signUpUseCase(users, auth, undefined, undefined);
|
||||
|
||||
const result = await useCase({
|
||||
username: "frank",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
});
|
||||
|
||||
expect(result.session.userId).toBeTruthy();
|
||||
expect(result.cookie.name).toBe("session");
|
||||
});
|
||||
|
||||
it("does NOT publish when sign-up fails (username taken)", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
await users.createUser(userFactory.build({ username: "eve" }));
|
||||
|
||||
const useCase = signUpUseCase(users, auth, bus, undefined);
|
||||
await expect(
|
||||
useCase({
|
||||
username: "eve",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
expect(bus.published).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("migrates anonymous consent when cc_consent cookie is present", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
const consent = new RecordingConsent();
|
||||
const consentFactory = (_userId: string) => Promise.resolve(consent);
|
||||
const useCase = signUpUseCase(users, auth, bus, consentFactory);
|
||||
|
||||
const result = await useCase({
|
||||
username: "grace",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
cookieHeader: "cc_consent=necessary,analytics; session=xyz",
|
||||
});
|
||||
|
||||
expect(consent.grants).toHaveLength(2);
|
||||
expect(consent.grants[0]).toEqual({
|
||||
category: "necessary",
|
||||
meta: { method: "signup-migration" },
|
||||
});
|
||||
expect(consent.grants[1]).toEqual({
|
||||
category: "analytics",
|
||||
meta: { method: "signup-migration" },
|
||||
});
|
||||
expect(result.clearCookie).toBeDefined();
|
||||
expect(result.clearCookie?.name).toBe("cc_consent");
|
||||
expect(result.clearCookie?.attributes.maxAge).toBe(0);
|
||||
});
|
||||
|
||||
it("does not migrate consent when no cc_consent cookie is present", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
const consent = new RecordingConsent();
|
||||
const consentFactory = (_userId: string) => Promise.resolve(consent);
|
||||
const useCase = signUpUseCase(users, auth, bus, consentFactory);
|
||||
|
||||
const result = await useCase({
|
||||
username: "henry",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
cookieHeader: "session=xyz",
|
||||
});
|
||||
|
||||
expect(consent.grants).toHaveLength(0);
|
||||
expect(result.clearCookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not migrate consent when consentFactory is absent", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
const useCase = signUpUseCase(users, auth, bus, undefined);
|
||||
|
||||
const result = await useCase({
|
||||
username: "iris",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
cookieHeader: "cc_consent=analytics",
|
||||
});
|
||||
|
||||
expect(result.clearCookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not migrate consent when cc_consent cookie has no value", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
const consent = new RecordingConsent();
|
||||
const consentFactory = (_userId: string) => Promise.resolve(consent);
|
||||
const useCase = signUpUseCase(users, auth, bus, consentFactory);
|
||||
|
||||
const result = await useCase({
|
||||
username: "jake",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
cookieHeader: "cc_consent=",
|
||||
});
|
||||
|
||||
expect(consent.grants).toHaveLength(0);
|
||||
expect(result.clearCookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses cookie header with malformed parts (no = sign)", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const bus = new RecordingEventBus();
|
||||
const consent = new RecordingConsent();
|
||||
const consentFactory = (_userId: string) => Promise.resolve(consent);
|
||||
const useCase = signUpUseCase(users, auth, bus, consentFactory);
|
||||
|
||||
const result = await useCase({
|
||||
username: "kate",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
cookieHeader: "malformedcookie; cc_consent=necessary",
|
||||
});
|
||||
|
||||
expect(consent.grants).toHaveLength(1);
|
||||
expect(result.clearCookie).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signUpUseCase output validation", () => {
|
||||
it("throws when authenticationService returns a malformed session", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = {
|
||||
hashPassword: async () => "hashed_x",
|
||||
generateUserId: () => "uid1",
|
||||
verifyPassword: async () => true,
|
||||
// session missing required fields → should fail signUpOutputSchema.parse
|
||||
createSession: async () => ({ session: { id: 123 }, cookie: null }),
|
||||
} as unknown as IAuthenticationService;
|
||||
|
||||
const bus = new RecordingEventBus();
|
||||
const useCase = signUpUseCase(users, auth, bus, undefined);
|
||||
await expect(
|
||||
useCase({
|
||||
username: "carol",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors the success shape", () => {
|
||||
expect(signUpOutputSchema).toBeDefined();
|
||||
const parsed = signUpOutputSchema.safeParse({
|
||||
session: { id: "s1", userId: "u1", expiresAt: new Date() },
|
||||
cookie: { name: "session", value: "s1", attributes: {} },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
119
packages/auth/src/application/use-cases/sign-up.use-case.ts
Normal file
119
packages/auth/src/application/use-cases/sign-up.use-case.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
EventBusProtocol,
|
||||
ConsentFactoryProtocol,
|
||||
} from "@repo/core-shared/di";
|
||||
import { userSignedUpEvent } from "../../events/user-signed-up.event";
|
||||
import { AuthenticationError } from "../../entities/errors/auth";
|
||||
import { cookieSchema } from "../../entities/models/cookie";
|
||||
import { sessionSchema } from "../../entities/models/session";
|
||||
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
|
||||
// Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent).
|
||||
const ANONYMOUS_CONSENT_COOKIE = "cc_consent";
|
||||
|
||||
function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const eqIdx = part.indexOf("=");
|
||||
if (eqIdx === -1) continue;
|
||||
const name = part.slice(0, eqIdx).trim();
|
||||
if (name !== ANONYMOUS_CONSENT_COOKIE) continue;
|
||||
const value = part.slice(eqIdx + 1).trim();
|
||||
const cats = value
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
return cats.length > 0 ? cats : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const signUpInputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
confirmPassword: z.string().min(6).max(255),
|
||||
cookieHeader: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((d) => d.password === d.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type SignUpInput = z.infer<typeof signUpInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const signUpOutputSchema = z.object({
|
||||
session: sessionSchema,
|
||||
cookie: cookieSchema,
|
||||
clearCookie: cookieSchema.optional(),
|
||||
});
|
||||
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type ISignUpUseCase = ReturnType<typeof signUpUseCase>;
|
||||
|
||||
export const signUpUseCase =
|
||||
(
|
||||
usersRepository: IUsersRepository,
|
||||
authenticationService: IAuthenticationService,
|
||||
bus: EventBusProtocol | undefined,
|
||||
consentFactory: ConsentFactoryProtocol | undefined,
|
||||
) =>
|
||||
async (input: SignUpInput): Promise<SignUpOutput> => {
|
||||
const existingUser = await usersRepository.getUserByUsername(
|
||||
input.username,
|
||||
);
|
||||
if (existingUser) {
|
||||
throw new AuthenticationError("Username taken");
|
||||
}
|
||||
|
||||
const passwordHash = await authenticationService.hashPassword(
|
||||
input.password,
|
||||
);
|
||||
const userId = authenticationService.generateUserId();
|
||||
|
||||
const newUser = await usersRepository.createUser({
|
||||
id: userId,
|
||||
username: input.username,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
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.
|
||||
if (bus) {
|
||||
await bus.publish(userSignedUpEvent, {
|
||||
userId: newUser.id,
|
||||
email: `${newUser.username}@example.local`,
|
||||
signedUpAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate anonymous consent when both a cookie header and a consent factory
|
||||
// are present. consentFactory is optional: absent when core-consent is not wired.
|
||||
const cookieState = input.cookieHeader
|
||||
? extractConsentFromCookieHeader(input.cookieHeader)
|
||||
: null;
|
||||
|
||||
let clearCookie: z.infer<typeof cookieSchema> | undefined;
|
||||
if (cookieState && consentFactory) {
|
||||
const consent = await consentFactory(newUser.id);
|
||||
for (const category of cookieState) {
|
||||
await consent.grant(category, { method: "signup-migration" });
|
||||
}
|
||||
clearCookie = {
|
||||
name: ANONYMOUS_CONSENT_COOKIE,
|
||||
value: "",
|
||||
attributes: { maxAge: 0, path: "/" },
|
||||
};
|
||||
}
|
||||
|
||||
return signUpOutputSchema.parse({ session, cookie, clearCookie });
|
||||
};
|
||||
1
packages/auth/src/config.ts
Normal file
1
packages/auth/src/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SESSION_COOKIE = "session";
|
||||
77
packages/auth/src/di/bind-dev-seed.test.ts
Normal file
77
packages/auth/src/di/bind-dev-seed.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||
import { bindDevSeedAuth } from "@/di/bind-dev-seed";
|
||||
import { authContainer } from "@/di/container";
|
||||
import { AUTH_SYMBOLS } from "@/di/symbols";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
|
||||
const noop = { tracer: new NoopTracer(), logger: new NoopLogger() };
|
||||
|
||||
describe("bindDevSeedAuth", () => {
|
||||
// Each test starts from the default empty-mock binding and tears down
|
||||
// afterwards so the global authContainer state stays clean for siblings.
|
||||
beforeEach(() => {
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
}
|
||||
authContainer
|
||||
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
||||
.to(MockUsersRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
}
|
||||
authContainer
|
||||
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
||||
.to(MockUsersRepository);
|
||||
});
|
||||
|
||||
it("populates the repository with the dev users", async () => {
|
||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = authContainer.get<IUsersRepository>(
|
||||
AUTH_SYMBOLS.IUsersRepository,
|
||||
);
|
||||
const alice = await repo.getUserByUsername("alice");
|
||||
const bob = await repo.getUserByUsername("bob");
|
||||
|
||||
expect(alice).toBeDefined();
|
||||
expect(bob).toBeDefined();
|
||||
});
|
||||
|
||||
it("seeds alice reachable by username", async () => {
|
||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = authContainer.get<IUsersRepository>(
|
||||
AUTH_SYMBOLS.IUsersRepository,
|
||||
);
|
||||
const alice = await repo.getUserByUsername("alice");
|
||||
|
||||
expect(alice).toBeDefined();
|
||||
expect(alice?.id).toBe("alice");
|
||||
expect(alice?.passwordHash).toBe("hashed_secret_alice");
|
||||
});
|
||||
|
||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const before = authContainer.get<IUsersRepository>(
|
||||
AUTH_SYMBOLS.IUsersRepository,
|
||||
);
|
||||
const beforeAlice = await before.getUserByUsername("alice");
|
||||
|
||||
await bindDevSeedAuth({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const after = authContainer.get<IUsersRepository>(
|
||||
AUTH_SYMBOLS.IUsersRepository,
|
||||
);
|
||||
const afterAlice = await after.getUserByUsername("alice");
|
||||
|
||||
expect(afterAlice?.username).toBe(beforeAlice?.username);
|
||||
// It's a fresh instance — not the previous one.
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
187
packages/auth/src/di/bind-dev-seed.ts
Normal file
187
packages/auth/src/di/bind-dev-seed.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import { authManifest } from "../feature.manifest";
|
||||
import { authContainer } from "./container";
|
||||
import { AUTH_SYMBOLS } from "./symbols";
|
||||
import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock";
|
||||
import { buildDevUsers } from "../__seeds__/dev";
|
||||
import { signInUseCase } from "../application/use-cases/sign-in.use-case";
|
||||
import { signUpUseCase } from "../application/use-cases/sign-up.use-case";
|
||||
import { signOutUseCase } from "../application/use-cases/sign-out.use-case";
|
||||
import { signInController } from "../interface-adapters/controllers/sign-in.controller";
|
||||
import { signUpController } from "../interface-adapters/controllers/sign-up.controller";
|
||||
import { signOutController } from "../interface-adapters/controllers/sign-out.controller";
|
||||
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../application/services/authentication.service.interface";
|
||||
|
||||
/**
|
||||
* Replace the default mock with a populated one for dev mode + storybook.
|
||||
*
|
||||
* Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with
|
||||
* `bindProductionAuth(config)`. Tests must NOT call this — they construct
|
||||
* `new MockUsersRepository()` directly and seed via factories per-test.
|
||||
*
|
||||
* The `IAuthenticationService` binding is left untouched; it resolves users
|
||||
* through DI from the newly seeded repo.
|
||||
*
|
||||
* Idempotent: safe to call multiple times; each call rebuilds a fresh
|
||||
* populated repo and rebinds the symbol.
|
||||
*/
|
||||
export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
|
||||
const {
|
||||
tracer,
|
||||
logger,
|
||||
bus,
|
||||
queue,
|
||||
realtime,
|
||||
realtimeRegistry,
|
||||
consentFactory,
|
||||
} = ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
authContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
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);
|
||||
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
}
|
||||
|
||||
const repo = new MockUsersRepository([], tracer, logger);
|
||||
for (const user of buildDevUsers()) {
|
||||
await repo.createUser(user);
|
||||
}
|
||||
|
||||
authContainer
|
||||
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
||||
.toConstantValue(repo);
|
||||
|
||||
// Need auth service from container for use cases
|
||||
const authService = authContainer.get<IAuthenticationService>(
|
||||
AUTH_SYMBOLS.IAuthenticationService,
|
||||
);
|
||||
|
||||
// Use cases
|
||||
const wrappedSignIn = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignInUseCase,
|
||||
factory: signInUseCase,
|
||||
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signIn",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit: ctx.rateLimit ?? new NoopRateLimit(),
|
||||
});
|
||||
const wrappedSignUp = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignUpUseCase,
|
||||
factory: signUpUseCase,
|
||||
deps: [repo, authService, bus, consentFactory],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signUp",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedSignOut = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignOutUseCase,
|
||||
factory: signOutUseCase,
|
||||
deps: [authService],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signOut",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Controllers
|
||||
for (const sym of [
|
||||
AUTH_SYMBOLS.ISignInController,
|
||||
AUTH_SYMBOLS.ISignUpController,
|
||||
AUTH_SYMBOLS.ISignOutController,
|
||||
]) {
|
||||
if (authContainer.isBound(sym)) authContainer.unbind(sym);
|
||||
}
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignInController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "auth.signIn", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "auth", layer: "controller", name: "auth.signIn" },
|
||||
signInController(wrappedSignIn),
|
||||
),
|
||||
),
|
||||
);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignUpController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "auth.signUp", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "auth", layer: "controller", name: "auth.signUp" },
|
||||
signUpController(wrappedSignUp),
|
||||
),
|
||||
),
|
||||
);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignOutController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "auth.signOut", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "auth", layer: "controller", name: "auth.signOut" },
|
||||
signOutController(wrappedSignOut),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check (dev-seed mode).
|
||||
assertFeatureConformance(
|
||||
authContainer,
|
||||
authManifest,
|
||||
{
|
||||
signIn: AUTH_SYMBOLS.ISignInUseCase,
|
||||
signUp: AUTH_SYMBOLS.ISignUpUseCase,
|
||||
signOut: AUTH_SYMBOLS.ISignOutUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
17
packages/auth/src/di/bind-production.smoke.test.ts
Normal file
17
packages/auth/src/di/bind-production.smoke.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import "reflect-metadata";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { bindProductionAuth } from "@/di/bind-production";
|
||||
|
||||
describe("bindProductionAuth — boot-time conformance", () => {
|
||||
it("binds every manifest use case through withSpan + withCapture", () => {
|
||||
expect(() =>
|
||||
bindProductionAuth({
|
||||
config: {} as SanitizedConfig,
|
||||
tracer: new NoopTracer(),
|
||||
logger: new NoopLogger(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
180
packages/auth/src/di/bind-production.ts
Normal file
180
packages/auth/src/di/bind-production.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import { authManifest } from "../feature.manifest";
|
||||
import { authContainer } from "./container";
|
||||
import { AUTH_SYMBOLS } from "./symbols";
|
||||
import { UsersRepository } from "../infrastructure/repositories/users.repository";
|
||||
import { AuthenticationService } from "../infrastructure/services/authentication.service";
|
||||
import { signInUseCase } from "../application/use-cases/sign-in.use-case";
|
||||
import { signUpUseCase } from "../application/use-cases/sign-up.use-case";
|
||||
import { signOutUseCase } from "../application/use-cases/sign-out.use-case";
|
||||
import { signInController } from "../interface-adapters/controllers/sign-in.controller";
|
||||
import { signUpController } from "../interface-adapters/controllers/sign-up.controller";
|
||||
import { signOutController } from "../interface-adapters/controllers/sign-out.controller";
|
||||
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../application/services/authentication.service.interface";
|
||||
|
||||
let bound = false;
|
||||
|
||||
export function bindProductionAuth(ctx: BindProductionContext): void {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
|
||||
const {
|
||||
config,
|
||||
tracer,
|
||||
logger,
|
||||
bus,
|
||||
queue,
|
||||
realtime,
|
||||
realtimeRegistry,
|
||||
consentFactory,
|
||||
} = ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
authContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
||||
}
|
||||
const authService = new AuthenticationService(config);
|
||||
authContainer
|
||||
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
||||
.toConstantValue(authService);
|
||||
|
||||
// Use cases
|
||||
const wrappedSignIn = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignInUseCase,
|
||||
factory: signInUseCase,
|
||||
deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signIn",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit: ctx.rateLimit ?? new NoopRateLimit(),
|
||||
});
|
||||
const wrappedSignUp = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignUpUseCase,
|
||||
factory: signUpUseCase,
|
||||
deps: [repo, authService, bus, consentFactory],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signUp",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedSignOut = wireUseCase({
|
||||
container: authContainer,
|
||||
symbol: AUTH_SYMBOLS.ISignOutUseCase,
|
||||
factory: signOutUseCase,
|
||||
deps: [authService],
|
||||
feature: "auth",
|
||||
layer: "use-case",
|
||||
name: "signOut",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Controllers — wrapped with span at bind time
|
||||
for (const sym of [
|
||||
AUTH_SYMBOLS.ISignInController,
|
||||
AUTH_SYMBOLS.ISignUpController,
|
||||
AUTH_SYMBOLS.ISignOutController,
|
||||
]) {
|
||||
if (authContainer.isBound(sym)) authContainer.unbind(sym);
|
||||
}
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignInController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "auth.signIn", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "auth", layer: "controller", name: "auth.signIn" },
|
||||
signInController(wrappedSignIn),
|
||||
),
|
||||
),
|
||||
);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignUpController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "auth.signUp", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "auth", layer: "controller", name: "auth.signUp" },
|
||||
signUpController(wrappedSignUp),
|
||||
),
|
||||
),
|
||||
);
|
||||
authContainer
|
||||
.bind(AUTH_SYMBOLS.ISignOutController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "auth.signOut", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "auth", layer: "controller", name: "auth.signOut" },
|
||||
signOutController(wrappedSignOut),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check: refuses to start if any use-case binding
|
||||
// is missing a required brand (withSpan / withCapture / withAudit).
|
||||
assertFeatureConformance(
|
||||
authContainer,
|
||||
authManifest,
|
||||
{
|
||||
signIn: AUTH_SYMBOLS.ISignInUseCase,
|
||||
signUp: AUTH_SYMBOLS.ISignUpUseCase,
|
||||
signOut: AUTH_SYMBOLS.ISignOutUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
29
packages/auth/src/di/bind-production.types.test.ts
Normal file
29
packages/auth/src/di/bind-production.types.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it } from "vitest";
|
||||
import type { ProductionUseCase } from "@repo/core-shared/conformance";
|
||||
import type { AuthManifest } from "@/feature.manifest";
|
||||
import type {
|
||||
SignInInput,
|
||||
SignInOutput,
|
||||
} from "@/application/use-cases/sign-in.use-case";
|
||||
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
|
||||
|
||||
describe("auth.signIn binding slot (type-level)", () => {
|
||||
it("rejects an unwrapped factory", () => {
|
||||
type Slot = ProductionUseCase<
|
||||
SignInInput,
|
||||
SignInOutput,
|
||||
AuthManifest["useCases"]["signIn"]
|
||||
>;
|
||||
|
||||
// Build the unwrapped factory exactly as it would be at the use-case file.
|
||||
// It returns a function with no brand attached — must not be assignable.
|
||||
const fakeRepo = {} as never;
|
||||
const fakeAuth = {} as never;
|
||||
const fakeRateLimit = {} as never;
|
||||
const unwrapped = signInUseCase(fakeRepo, fakeAuth, fakeRateLimit);
|
||||
|
||||
// @ts-expect-error — unwrapped factory has no __instrumented / __captured brand
|
||||
const _bad: Slot = unwrapped;
|
||||
void _bad;
|
||||
});
|
||||
});
|
||||
59
packages/auth/src/di/container.test.ts
Normal file
59
packages/auth/src/di/container.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { authContainer } from "./container";
|
||||
import { AUTH_SYMBOLS } from "./symbols";
|
||||
import { AuthModule } from "./module";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
import type { ISignInUseCase } from "@/application/use-cases/sign-in.use-case";
|
||||
import type { ISignInController } from "@/interface-adapters/controllers/sign-in.controller";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("authContainer", () => {
|
||||
beforeEach(() => {
|
||||
authContainer.unbindAll();
|
||||
authContainer.load(AuthModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("resolves IUsersRepository to MockUsersRepository by default", () => {
|
||||
const repo = authContainer.get<IUsersRepository>(
|
||||
AUTH_SYMBOLS.IUsersRepository,
|
||||
);
|
||||
expect(repo).toBeInstanceOf(MockUsersRepository);
|
||||
});
|
||||
|
||||
it("resolves IAuthenticationService to MockAuthenticationService by default", () => {
|
||||
const service = authContainer.get<IAuthenticationService>(
|
||||
AUTH_SYMBOLS.IAuthenticationService,
|
||||
);
|
||||
expect(service).toBeInstanceOf(MockAuthenticationService);
|
||||
});
|
||||
|
||||
it("authentication service receives users repository via constructor injection", async () => {
|
||||
const service = authContainer.get<IAuthenticationService>(
|
||||
AUTH_SYMBOLS.IAuthenticationService,
|
||||
);
|
||||
const user = userFactory.build({ id: "1", username: "alice", passwordHash: "hashed_password_alice" });
|
||||
const { session, cookie } = await service.createSession(user);
|
||||
expect(session.userId).toBe("1");
|
||||
expect(cookie.value).toBe(session.id);
|
||||
|
||||
const validated = await service.validateSession(session.id);
|
||||
expect(validated.user.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("resolves ISignInUseCase via toDynamicValue binding", () => {
|
||||
const useCase = authContainer.get<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase);
|
||||
expect(typeof useCase).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves ISignInController via toDynamicValue binding", () => {
|
||||
const controller = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
|
||||
expect(typeof controller).toBe("function");
|
||||
});
|
||||
});
|
||||
6
packages/auth/src/di/container.ts
Normal file
6
packages/auth/src/di/container.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
import { AuthModule } from "./module";
|
||||
|
||||
export const authContainer = new Container({ defaultScope: "Singleton" });
|
||||
authContainer.load(AuthModule);
|
||||
92
packages/auth/src/di/module.ts
Normal file
92
packages/auth/src/di/module.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ContainerModule, type interfaces } from "inversify";
|
||||
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../application/services/authentication.service.interface";
|
||||
import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "../infrastructure/services/authentication.service.mock";
|
||||
import {
|
||||
signInUseCase,
|
||||
type ISignInUseCase,
|
||||
} from "../application/use-cases/sign-in.use-case";
|
||||
import {
|
||||
signUpUseCase,
|
||||
type ISignUpUseCase,
|
||||
} from "../application/use-cases/sign-up.use-case";
|
||||
import {
|
||||
signOutUseCase,
|
||||
type ISignOutUseCase,
|
||||
} from "../application/use-cases/sign-out.use-case";
|
||||
import {
|
||||
signInController,
|
||||
type ISignInController,
|
||||
} from "../interface-adapters/controllers/sign-in.controller";
|
||||
import {
|
||||
signUpController,
|
||||
type ISignUpController,
|
||||
} from "../interface-adapters/controllers/sign-up.controller";
|
||||
import {
|
||||
signOutController,
|
||||
type ISignOutController,
|
||||
} from "../interface-adapters/controllers/sign-out.controller";
|
||||
import { AUTH_SYMBOLS } from "./symbols";
|
||||
|
||||
export const AuthModule = new ContainerModule((bind: interfaces.Bind) => {
|
||||
bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository);
|
||||
bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService).to(
|
||||
MockAuthenticationService,
|
||||
);
|
||||
|
||||
bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) =>
|
||||
signInUseCase(
|
||||
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
|
||||
ctx.container.get<IAuthenticationService>(
|
||||
AUTH_SYMBOLS.IAuthenticationService,
|
||||
),
|
||||
new NoopRateLimit(),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase).toDynamicValue((ctx) =>
|
||||
// No default bus — real cross-feature wiring runs through
|
||||
// bindProductionAuth / bindDevSeedAuth where bindAll() passes a shared
|
||||
// bus instance when @repo/core-events is scaffolded.
|
||||
signUpUseCase(
|
||||
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
|
||||
ctx.container.get<IAuthenticationService>(
|
||||
AUTH_SYMBOLS.IAuthenticationService,
|
||||
),
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
|
||||
bind<ISignOutUseCase>(AUTH_SYMBOLS.ISignOutUseCase).toDynamicValue((ctx) =>
|
||||
signOutUseCase(
|
||||
ctx.container.get<IAuthenticationService>(
|
||||
AUTH_SYMBOLS.IAuthenticationService,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ISignInController>(AUTH_SYMBOLS.ISignInController).toDynamicValue(
|
||||
(ctx) =>
|
||||
signInController(
|
||||
ctx.container.get<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ISignUpController>(AUTH_SYMBOLS.ISignUpController).toDynamicValue(
|
||||
(ctx) =>
|
||||
signUpController(
|
||||
ctx.container.get<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ISignOutController>(AUTH_SYMBOLS.ISignOutController).toDynamicValue(
|
||||
(ctx) =>
|
||||
signOutController(
|
||||
ctx.container.get<ISignOutUseCase>(AUTH_SYMBOLS.ISignOutUseCase),
|
||||
),
|
||||
);
|
||||
});
|
||||
15
packages/auth/src/di/symbols.ts
Normal file
15
packages/auth/src/di/symbols.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const AUTH_SYMBOLS = {
|
||||
IUsersRepository: Symbol.for("auth:IUsersRepository"),
|
||||
IAuthenticationService: Symbol.for("auth:IAuthenticationService"),
|
||||
// Use cases
|
||||
ISignInUseCase: Symbol.for("auth:ISignInUseCase"),
|
||||
ISignUpUseCase: Symbol.for("auth:ISignUpUseCase"),
|
||||
ISignOutUseCase: Symbol.for("auth:ISignOutUseCase"),
|
||||
// Controllers
|
||||
ISignInController: Symbol.for("auth:ISignInController"),
|
||||
ISignUpController: Symbol.for("auth:ISignUpController"),
|
||||
ISignOutController: Symbol.for("auth:ISignOutController"),
|
||||
// <gen:event-handler-symbols>
|
||||
// <gen:job-symbols>
|
||||
// <gen:realtime-handler-symbols>
|
||||
} as const;
|
||||
27
packages/auth/src/entities/errors/auth.ts
Normal file
27
packages/auth/src/entities/errors/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthenticatedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "UnauthenticatedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TooManyRequestsError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "TooManyRequestsError";
|
||||
}
|
||||
}
|
||||
6
packages/auth/src/entities/errors/common.ts
Normal file
6
packages/auth/src/entities/errors/common.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
49
packages/auth/src/entities/errors/errors.test.ts
Normal file
49
packages/auth/src/entities/errors/errors.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AuthenticationError,
|
||||
UnauthenticatedError,
|
||||
UnauthorizedError,
|
||||
TooManyRequestsError,
|
||||
} from "./auth";
|
||||
import { InputParseError } from "./common";
|
||||
|
||||
describe("AuthenticationError", () => {
|
||||
it("is an instance of Error with the given message", () => {
|
||||
const err = new AuthenticationError("bad credentials");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("bad credentials");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnauthenticatedError", () => {
|
||||
it("is an instance of Error with the given message", () => {
|
||||
const err = new UnauthenticatedError("not logged in");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("not logged in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnauthorizedError", () => {
|
||||
it("is an instance of Error with the given message", () => {
|
||||
const err = new UnauthorizedError("forbidden");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputParseError", () => {
|
||||
it("is an instance of Error with the given message", () => {
|
||||
const err = new InputParseError("invalid input");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("invalid input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TooManyRequestsError", () => {
|
||||
it("is an instance of Error with the given message", () => {
|
||||
const err = new TooManyRequestsError("too many attempts");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("too many attempts");
|
||||
expect(err.name).toBe("TooManyRequestsError");
|
||||
});
|
||||
});
|
||||
47
packages/auth/src/entities/models/cookie.test.ts
Normal file
47
packages/auth/src/entities/models/cookie.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cookieSchema } from "./cookie";
|
||||
|
||||
describe("cookieSchema", () => {
|
||||
it("accepts a minimal cookie with empty attributes", () => {
|
||||
const result = cookieSchema.parse({
|
||||
name: "session",
|
||||
value: "abc123",
|
||||
attributes: {},
|
||||
});
|
||||
expect(result.name).toBe("session");
|
||||
expect(result.value).toBe("abc123");
|
||||
});
|
||||
|
||||
it("accepts a fully-populated cookie", () => {
|
||||
const result = cookieSchema.parse({
|
||||
name: "session",
|
||||
value: "abc123",
|
||||
attributes: {
|
||||
secure: true,
|
||||
path: "/",
|
||||
domain: "example.com",
|
||||
sameSite: "strict",
|
||||
httpOnly: true,
|
||||
maxAge: 3600,
|
||||
expires: new Date(),
|
||||
},
|
||||
});
|
||||
expect(result.attributes.sameSite).toBe("strict");
|
||||
});
|
||||
|
||||
it("rejects an invalid sameSite value", () => {
|
||||
expect(() =>
|
||||
cookieSchema.parse({
|
||||
name: "session",
|
||||
value: "abc123",
|
||||
attributes: { sameSite: "invalid" },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a missing required field", () => {
|
||||
expect(() =>
|
||||
cookieSchema.parse({ name: "session", attributes: {} }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
19
packages/auth/src/entities/models/cookie.ts
Normal file
19
packages/auth/src/entities/models/cookie.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const cookieAttributesSchema = z.object({
|
||||
secure: z.boolean().optional(),
|
||||
path: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
sameSite: z.enum(["lax", "strict", "none"]).optional(),
|
||||
httpOnly: z.boolean().optional(),
|
||||
maxAge: z.number().optional(),
|
||||
expires: z.date().optional(),
|
||||
});
|
||||
|
||||
export const cookieSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
attributes: cookieAttributesSchema,
|
||||
});
|
||||
|
||||
export type Cookie = z.infer<typeof cookieSchema>;
|
||||
23
packages/auth/src/entities/models/session.test.ts
Normal file
23
packages/auth/src/entities/models/session.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionSchema } from "./session";
|
||||
|
||||
describe("sessionSchema", () => {
|
||||
it("accepts a valid session", () => {
|
||||
const result = sessionSchema.parse({
|
||||
id: "session_1",
|
||||
userId: "1",
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
expect(result.userId).toBe("1");
|
||||
});
|
||||
|
||||
it("rejects non-Date expiresAt", () => {
|
||||
expect(() =>
|
||||
sessionSchema.parse({
|
||||
id: "session_1",
|
||||
userId: "1",
|
||||
expiresAt: "2026-05-04",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
9
packages/auth/src/entities/models/session.ts
Normal file
9
packages/auth/src/entities/models/session.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const sessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.date(),
|
||||
});
|
||||
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
33
packages/auth/src/entities/models/user.test.ts
Normal file
33
packages/auth/src/entities/models/user.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { userSchema } from "./user";
|
||||
|
||||
describe("userSchema", () => {
|
||||
it("accepts a valid user", () => {
|
||||
const result = userSchema.parse({
|
||||
id: "1",
|
||||
username: "alice",
|
||||
passwordHash: "hashed_password_1",
|
||||
});
|
||||
expect(result.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("rejects username shorter than 3 chars", () => {
|
||||
expect(() =>
|
||||
userSchema.parse({
|
||||
id: "1",
|
||||
username: "ab",
|
||||
passwordHash: "hashed_password_1",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects passwordHash shorter than 6 chars", () => {
|
||||
expect(() =>
|
||||
userSchema.parse({
|
||||
id: "1",
|
||||
username: "alice",
|
||||
passwordHash: "abc",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
9
packages/auth/src/entities/models/user.ts
Normal file
9
packages/auth/src/entities/models/user.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string().min(3).max(31),
|
||||
passwordHash: z.string().min(6).max(255),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
49
packages/auth/src/events/user-signed-up.event.test.ts
Normal file
49
packages/auth/src/events/user-signed-up.event.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// packages/auth/src/events/user-signed-up.event.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { userSignedUpEventSchema, userSignedUpEvent } from "@/events/user-signed-up.event";
|
||||
|
||||
describe("userSignedUpEvent", () => {
|
||||
it("has the correct wire name", () => {
|
||||
expect(userSignedUpEvent.name).toBe("auth.user.signed-up");
|
||||
});
|
||||
|
||||
it("accepts a valid payload", () => {
|
||||
const payload = {
|
||||
userId: "user_123",
|
||||
email: "alice@example.com",
|
||||
signedUpAt: "2026-05-08T12:00:00.000Z",
|
||||
};
|
||||
expect(() => userSignedUpEventSchema.parse(payload)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid email", () => {
|
||||
expect(() =>
|
||||
userSignedUpEventSchema.parse({
|
||||
userId: "u1",
|
||||
email: "not-an-email",
|
||||
signedUpAt: "2026-05-08T12:00:00.000Z",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid datetime", () => {
|
||||
expect(() =>
|
||||
userSignedUpEventSchema.parse({
|
||||
userId: "u1",
|
||||
email: "alice@example.com",
|
||||
signedUpAt: "yesterday",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown fields (strict)", () => {
|
||||
expect(() =>
|
||||
userSignedUpEventSchema.parse({
|
||||
userId: "u1",
|
||||
email: "alice@example.com",
|
||||
signedUpAt: "2026-05-08T12:00:00.000Z",
|
||||
extraField: "no",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
19
packages/auth/src/events/user-signed-up.event.ts
Normal file
19
packages/auth/src/events/user-signed-up.event.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// packages/auth/src/events/user-signed-up.event.ts
|
||||
import { z } from "zod";
|
||||
|
||||
export const userSignedUpEventSchema = z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
email: z.string().email(),
|
||||
signedUpAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UserSignedUpEvent = z.infer<typeof userSignedUpEventSchema>;
|
||||
|
||||
// Inline event descriptor — core-events is optional. The shape { name, schema }
|
||||
// satisfies EventBusProtocol.publish / subscribe when the bus is present.
|
||||
export const userSignedUpEvent = {
|
||||
name: "auth.user.signed-up" as const,
|
||||
schema: userSignedUpEventSchema,
|
||||
};
|
||||
65
packages/auth/src/feature.manifest.ts
Normal file
65
packages/auth/src/feature.manifest.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* The auth feature's conformance manifest. Drives binding-slot types in
|
||||
* `di/bind-production.ts` and is read by ESLint, the boot assertion, and
|
||||
* the CI drift gate (later milestones).
|
||||
*
|
||||
* Conventions:
|
||||
* - `mutates: true` for any use case that creates, updates, or deletes state
|
||||
* - `audits` lists every audit event the use case emits (must match calls
|
||||
* to `auditLog.record(...)` in the factory body — ESLint enforces this
|
||||
* in a later story)
|
||||
* - `publishes` / `consumes` cover cross-feature events through `IEventBus`
|
||||
*/
|
||||
export const authManifest = defineFeature({
|
||||
name: "auth",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
signIn: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
rateLimit: [
|
||||
{ name: "ip", window: "1m", budget: 5 },
|
||||
{ name: "account", window: "1h", budget: 10 },
|
||||
],
|
||||
},
|
||||
signUp: {
|
||||
mutates: true,
|
||||
audits: [],
|
||||
publishes: ["auth.user.signed-up"],
|
||||
consumes: [],
|
||||
},
|
||||
signOut: {
|
||||
mutates: true,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
coverage: {
|
||||
bands: {
|
||||
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
|
||||
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
"use-cases": {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
controllers: {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
mutationTargets: ["entities", "use-cases"],
|
||||
},
|
||||
} as const);
|
||||
|
||||
export type AuthManifest = typeof authManifest;
|
||||
50
packages/auth/src/index.ts
Normal file
50
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type { User } from "./entities/models/user";
|
||||
export type { Session } from "./entities/models/session";
|
||||
export type { Cookie } from "./entities/models/cookie";
|
||||
export type { AuthRouter } from "./integrations/api/router";
|
||||
export {
|
||||
AuthenticationError,
|
||||
UnauthenticatedError,
|
||||
UnauthorizedError,
|
||||
TooManyRequestsError,
|
||||
} from "./entities/errors/auth";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
export { SESSION_COOKIE } from "./config";
|
||||
|
||||
// Use case schemas + types
|
||||
export {
|
||||
signInInputSchema,
|
||||
signInOutputSchema,
|
||||
type SignInInput,
|
||||
type SignInOutput,
|
||||
type ISignInUseCase,
|
||||
} from "./application/use-cases/sign-in.use-case";
|
||||
export {
|
||||
signUpInputSchema,
|
||||
signUpOutputSchema,
|
||||
type SignUpInput,
|
||||
type SignUpOutput,
|
||||
type ISignUpUseCase,
|
||||
} from "./application/use-cases/sign-up.use-case";
|
||||
export {
|
||||
signOutInputSchema,
|
||||
type SignOutInput,
|
||||
type ISignOutUseCase,
|
||||
} from "./application/use-cases/sign-out.use-case";
|
||||
|
||||
// Controller type aliases
|
||||
export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller";
|
||||
export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller";
|
||||
export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller";
|
||||
|
||||
// <gen:events>
|
||||
export {
|
||||
userSignedUpEvent,
|
||||
userSignedUpEventSchema,
|
||||
type UserSignedUpEvent,
|
||||
} from "./events/user-signed-up.event";
|
||||
// <gen:realtime-channels>
|
||||
|
||||
// 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";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe } from "vitest";
|
||||
import { RecordingTracer } from "@repo/core-testing/instrumentation";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { usersRepositoryContract } from "@/__contracts__/users-repository.contract";
|
||||
|
||||
describe("MockUsersRepository", () => {
|
||||
const tracer = new RecordingTracer();
|
||||
// Start with empty store so contract tests run from a clean slate.
|
||||
usersRepositoryContract.run(
|
||||
() => new MockUsersRepository([], tracer),
|
||||
{ tracer: () => tracer },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import "reflect-metadata";
|
||||
import { injectable } from "inversify";
|
||||
import {
|
||||
NoopTracer,
|
||||
NoopLogger,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
|
||||
import type { IUsersRepository } from "../../application/repositories/users.repository.interface";
|
||||
import type { User } from "../../entities/models/user";
|
||||
|
||||
const DEFAULT_SEED: User[] = [
|
||||
{ id: "1", username: "alice", passwordHash: "hashed_password_alice" },
|
||||
{ id: "2", username: "bob", passwordHash: "hashed_password_bob" },
|
||||
];
|
||||
|
||||
@injectable()
|
||||
export class MockUsersRepository implements IUsersRepository {
|
||||
private _users: User[];
|
||||
private tracer: ITracer;
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
initialUsers: User[] = DEFAULT_SEED,
|
||||
tracer: ITracer = new NoopTracer(),
|
||||
logger: ILogger = new NoopLogger(),
|
||||
) {
|
||||
this._users = [...initialUsers];
|
||||
this.tracer = tracer;
|
||||
this.logger = logger;
|
||||
void this.logger; // currently unused; reserved for future mock-thrown captures
|
||||
}
|
||||
|
||||
async getUser(id: string): Promise<User | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "users.getUser", op: "repository", attributes: { id } },
|
||||
async (span) => {
|
||||
const found = this._users.find((u) => u.id === id);
|
||||
span.setAttribute("found", Boolean(found));
|
||||
return found;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{
|
||||
name: "users.getUserByUsername",
|
||||
op: "repository",
|
||||
attributes: { emailDomain: username.includes("@") ? (username.split("@")[1] ?? "(invalid)") : username },
|
||||
},
|
||||
async (span) => {
|
||||
const found = this._users.find((u) => u.username === username);
|
||||
span.setAttribute("found", Boolean(found));
|
||||
return found;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async createUser(input: User): Promise<User> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "users.createUser", op: "repository", attributes: { id: input.id } },
|
||||
async (span) => {
|
||||
this._users.push(input);
|
||||
span.setAttribute("created", true);
|
||||
return input;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
|
||||
// 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();
|
||||
const repo = new MockUsersRepository([], tracer, logger);
|
||||
await repo.getUser("missing");
|
||||
expect(tracer.spans).toHaveLength(1);
|
||||
expect(tracer.spans[0]).toMatchObject({
|
||||
name: "users.getUser",
|
||||
op: "repository",
|
||||
});
|
||||
expect(tracer.spans[0]!.attributes.id).toBe("missing");
|
||||
expect(tracer.spans[0]!.attributes.found).toBe(false);
|
||||
});
|
||||
|
||||
it("getUserByUsername emits a span with emailDomain attribute", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const repo = new MockUsersRepository(
|
||||
[{ id: "1", username: "alice", passwordHash: "hash" }],
|
||||
tracer,
|
||||
);
|
||||
await repo.getUserByUsername("alice");
|
||||
expect(tracer.findSpan("users.getUserByUsername")).toBeDefined();
|
||||
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",
|
||||
});
|
||||
expect(tracer.findSpan("users.createUser")).toBeDefined();
|
||||
expect(tracer.findSpan("users.createUser")!.attributes.created).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, vi, beforeEach } from "vitest";
|
||||
import { RecordingTracer } from "@repo/core-testing/instrumentation";
|
||||
import { UsersRepository } from "@/infrastructure/repositories/users.repository";
|
||||
import { usersRepositoryContract } from "@/__contracts__/users-repository.contract";
|
||||
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
||||
|
||||
vi.mock("payload", () => ({ getPayload: vi.fn() }));
|
||||
|
||||
function buildPayloadStub() {
|
||||
const store = new Map<string, Record<string, unknown>>();
|
||||
|
||||
return {
|
||||
create: vi.fn(
|
||||
async ({
|
||||
data,
|
||||
}: {
|
||||
collection: string;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess?: boolean;
|
||||
}) => {
|
||||
const doc = { ...data };
|
||||
store.set(String(doc.id), doc);
|
||||
return doc;
|
||||
},
|
||||
),
|
||||
find: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
collection: string;
|
||||
where?: { username?: { equals: string } };
|
||||
limit?: number;
|
||||
overrideAccess?: boolean;
|
||||
}) => {
|
||||
const all = Array.from(store.values());
|
||||
if (where?.username?.equals) {
|
||||
return { docs: all.filter((u) => u.username === where.username?.equals) };
|
||||
}
|
||||
return { docs: all };
|
||||
},
|
||||
),
|
||||
findByID: vi.fn(
|
||||
async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => {
|
||||
return store.get(String(id)) ?? null;
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("UsersRepository", () => {
|
||||
describe("contract", () => {
|
||||
const tracer = new RecordingTracer();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
usersRepositoryContract.run(
|
||||
async () => {
|
||||
const stub = buildPayloadStub();
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
return new UsersRepository(stubPayloadConfig, tracer);
|
||||
},
|
||||
{ tracer: () => tracer },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import {
|
||||
NoopTracer,
|
||||
NoopLogger,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { IUsersRepository } from "../../application/repositories/users.repository.interface";
|
||||
import { type User } from "../../entities/models/user";
|
||||
|
||||
const FEATURE = "auth" as const;
|
||||
const REPO = "users" as const;
|
||||
|
||||
export class UsersRepository implements IUsersRepository {
|
||||
private config: SanitizedConfig;
|
||||
private tracer: ITracer;
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
config: SanitizedConfig,
|
||||
tracer: ITracer = new NoopTracer(),
|
||||
logger: ILogger = new NoopLogger(),
|
||||
) {
|
||||
this.config = config;
|
||||
this.tracer = tracer;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async getUser(id: string): Promise<User | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "users.getUser", op: "repository", attributes: { id } },
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const result = await payload.findByID({
|
||||
collection: "users",
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const found = Boolean(result);
|
||||
span.setAttribute("found", found);
|
||||
return result ? this.toDomain(result as Record<string, unknown>) : undefined;
|
||||
} catch (err) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"status" in err &&
|
||||
(err as { status: unknown }).status === 404
|
||||
) {
|
||||
span.setAttribute("found", false);
|
||||
return undefined;
|
||||
}
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: FEATURE, repo: REPO, method: "getUser" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{
|
||||
name: "users.getUserByUsername",
|
||||
op: "repository",
|
||||
attributes: { emailDomain: username.includes("@") ? (username.split("@")[1] ?? "(invalid)") : username },
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const { docs } = await payload.find({
|
||||
collection: "users",
|
||||
where: { username: { equals: username } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const doc = docs[0];
|
||||
span.setAttribute("found", Boolean(doc));
|
||||
return doc ? this.toDomain(doc as Record<string, unknown>) : undefined;
|
||||
} catch (err) {
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: FEATURE, repo: REPO, method: "getUserByUsername" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async createUser(input: User): Promise<User> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "users.createUser", op: "repository", attributes: { id: input.id } },
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const created = await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
id: input.id,
|
||||
username: input.username,
|
||||
passwordHash: input.passwordHash,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
span.setAttribute("created", true);
|
||||
return this.toDomain(created as Record<string, unknown>);
|
||||
} catch (err) {
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: FEATURE, repo: REPO, method: "createUser" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private toDomain(doc: Record<string, unknown>): User {
|
||||
return {
|
||||
id: doc.id as string,
|
||||
username: doc.username as string,
|
||||
passwordHash: doc.passwordHash as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import "reflect-metadata";
|
||||
import { inject, injectable } from "inversify";
|
||||
|
||||
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
|
||||
import type { IUsersRepository } from "../../application/repositories/users.repository.interface";
|
||||
import { UnauthenticatedError } from "../../entities/errors/auth";
|
||||
import { sessionSchema, type Session } from "../../entities/models/session";
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import type { User } from "../../entities/models/user";
|
||||
import { AUTH_SYMBOLS } from "../../di/symbols";
|
||||
import { SESSION_COOKIE } from "../../config";
|
||||
|
||||
@injectable()
|
||||
export class MockAuthenticationService implements IAuthenticationService {
|
||||
private _sessions: Record<string, { session: Session; user: User }> = {};
|
||||
|
||||
constructor(
|
||||
@inject(AUTH_SYMBOLS.IUsersRepository)
|
||||
private _usersRepository: IUsersRepository,
|
||||
) {}
|
||||
|
||||
generateUserId(): string {
|
||||
return (Math.random() + 1).toString(36).substring(7);
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return `hashed_${password}`;
|
||||
}
|
||||
|
||||
async verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return hash === `hashed_${password}`;
|
||||
}
|
||||
|
||||
async validateSession(
|
||||
sessionId: string,
|
||||
): Promise<{ user: User; session: Session }> {
|
||||
const result = this._sessions[sessionId];
|
||||
if (!result) {
|
||||
throw new UnauthenticatedError("Unauthenticated");
|
||||
}
|
||||
const user = await this._usersRepository.getUser(result.user.id);
|
||||
if (!user) {
|
||||
throw new UnauthenticatedError("Unauthenticated");
|
||||
}
|
||||
return { user, session: result.session };
|
||||
}
|
||||
|
||||
async createSession(
|
||||
user: User,
|
||||
): Promise<{ session: Session; cookie: Cookie }> {
|
||||
const session = sessionSchema.parse({
|
||||
id: "session_" + user.id,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400000 * 7),
|
||||
});
|
||||
const cookie: Cookie = {
|
||||
name: SESSION_COOKIE,
|
||||
value: session.id,
|
||||
attributes: {},
|
||||
};
|
||||
this._sessions[session.id] = { session, user };
|
||||
return { session, cookie };
|
||||
}
|
||||
|
||||
async invalidateSession(
|
||||
sessionId: string,
|
||||
): Promise<{ blankCookie: Cookie }> {
|
||||
delete this._sessions[sessionId];
|
||||
return {
|
||||
blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AuthenticationService } from "@/infrastructure/services/authentication.service";
|
||||
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
||||
|
||||
describe("AuthenticationService", () => {
|
||||
const service = new AuthenticationService(stubPayloadConfig);
|
||||
|
||||
describe("generateUserId", () => {
|
||||
it("returns a UUID string", () => {
|
||||
const id = service.generateUserId();
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unique values", () => {
|
||||
const ids = Array.from({ length: 5 }, () => service.generateUserId());
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword + verifyPassword", () => {
|
||||
it("round-trips: hash then verify returns true", async () => {
|
||||
const hash = await service.hashPassword("my-secret");
|
||||
const valid = await service.verifyPassword(hash, "my-secret");
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for wrong password", async () => {
|
||||
const hash = await service.hashPassword("my-secret");
|
||||
const valid = await service.verifyPassword(hash, "wrong-password");
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for malformed stored hash", async () => {
|
||||
const valid = await service.verifyPassword(
|
||||
"not-a-valid-hash",
|
||||
"anything",
|
||||
);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session methods (require Payload)", () => {
|
||||
// createSession and validateSession call getPayload() internally,
|
||||
// so they require a running Payload instance. These are exercised
|
||||
// by the mock service in use-case tests and by integration tests.
|
||||
// Here we only test invalidateSession (no Payload dependency).
|
||||
|
||||
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
|
||||
const { blankCookie } = await service.invalidateSession("any-token");
|
||||
expect(blankCookie.name).toBe("payload-token");
|
||||
expect(blankCookie.value).toBe("");
|
||||
expect(blankCookie.attributes.maxAge).toBe(0);
|
||||
expect(blankCookie.attributes.httpOnly).toBe(true);
|
||||
expect(blankCookie.attributes.path).toBe("/");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import crypto from "node:crypto";
|
||||
import { getPayload, type SanitizedConfig } from "payload";
|
||||
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import type { Session } from "../../entities/models/session";
|
||||
import type { User } from "../../entities/models/user";
|
||||
|
||||
const SALT_LENGTH = 16;
|
||||
const KEY_LENGTH = 64;
|
||||
const ITERATIONS = 100_000;
|
||||
const DIGEST = "sha512";
|
||||
const SEPARATOR = ":";
|
||||
|
||||
const COOKIE_NAME = "payload-token";
|
||||
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
|
||||
|
||||
export class AuthenticationService implements IAuthenticationService {
|
||||
constructor(private config: SanitizedConfig) {}
|
||||
|
||||
generateUserId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
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"));
|
||||
},
|
||||
);
|
||||
});
|
||||
return `${salt}${SEPARATOR}${hash}`;
|
||||
}
|
||||
|
||||
async verifyPassword(storedHash: string, password: string): Promise<boolean> {
|
||||
const parts = storedHash.split(SEPARATOR);
|
||||
if (parts.length !== 2) return false;
|
||||
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"));
|
||||
},
|
||||
);
|
||||
});
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expectedHash, "hex"),
|
||||
Buffer.from(actualHash, "hex"),
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
user: User,
|
||||
): Promise<{ session: Session; cookie: Cookie }> {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
|
||||
|
||||
const token = this.signToken(user.id, payload.secret);
|
||||
|
||||
const session: Session = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
};
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
attributes: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_DURATION_SECONDS,
|
||||
},
|
||||
};
|
||||
|
||||
return { session, cookie };
|
||||
}
|
||||
|
||||
async validateSession(
|
||||
token: string,
|
||||
): Promise<{ user: User; session: Session }> {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const decoded = this.verifyToken(token, payload.secret);
|
||||
if (!decoded) throw new Error("Invalid or expired session token");
|
||||
|
||||
const userDoc = await payload.findByID({
|
||||
collection: "users" as "users",
|
||||
id: decoded.id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const user: User = {
|
||||
id: userDoc.id as string,
|
||||
username: (userDoc as Record<string, unknown>).username as string,
|
||||
passwordHash: (userDoc as Record<string, unknown>).passwordHash as string,
|
||||
};
|
||||
|
||||
const session: Session = {
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(decoded.exp * 1000),
|
||||
};
|
||||
|
||||
return { user, session };
|
||||
}
|
||||
|
||||
async invalidateSession(
|
||||
_sessionId: string,
|
||||
): Promise<{ blankCookie: Cookie }> {
|
||||
return {
|
||||
blankCookie: {
|
||||
name: COOKIE_NAME,
|
||||
value: "",
|
||||
attributes: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
maxAge: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
|
||||
private signToken(userId: string, secret: string): string {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
||||
).toString("base64url");
|
||||
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({ id: userId, collection: "users", exp }),
|
||||
).toString("base64url");
|
||||
const signature = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
/** Verify and decode a HS256 JWT. Returns null on invalid/expired token. */
|
||||
private verifyToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): { id: string; exp: number } | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [header, body, signature] = parts as [string, string, string];
|
||||
const expected = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
if (signature !== expected) return null;
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
||||
id: string;
|
||||
exp: number;
|
||||
};
|
||||
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/auth/src/integrations/api/procedures.ts
Normal file
20
packages/auth/src/integrations/api/procedures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
|
||||
import {
|
||||
AuthenticationError,
|
||||
UnauthenticatedError,
|
||||
UnauthorizedError,
|
||||
TooManyRequestsError,
|
||||
} from "../../entities/errors/auth";
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
|
||||
export const authProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([
|
||||
[InputParseError, "BAD_REQUEST"],
|
||||
[AuthenticationError, "UNAUTHORIZED"],
|
||||
[UnauthenticatedError, "UNAUTHORIZED"],
|
||||
[UnauthorizedError, "FORBIDDEN"],
|
||||
[TooManyRequestsError, "TOO_MANY_REQUESTS"],
|
||||
]),
|
||||
);
|
||||
74
packages/auth/src/integrations/api/router.test.ts
Normal file
74
packages/auth/src/integrations/api/router.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { authRouter } from "@/integrations/api/router";
|
||||
import { authContainer } from "@/di/container";
|
||||
import { AUTH_SYMBOLS } from "@/di/symbols";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
|
||||
describe("authRouter", () => {
|
||||
it("exposes signIn, signUp, signOut procedures", () => {
|
||||
const names = Object.keys(authRouter._def.procedures);
|
||||
expect(names).toContain("signIn");
|
||||
expect(names).toContain("signUp");
|
||||
expect(names).toContain("signOut");
|
||||
});
|
||||
|
||||
it("signIn returns a cookie via container-resolved controller", async () => {
|
||||
// The router resolves controllers via the authContainer (default mock bindings).
|
||||
// MockUsersRepository is seeded with alice/password_alice by default.
|
||||
const caller = authRouter.createCaller({});
|
||||
const result = await caller.signIn({
|
||||
username: "alice",
|
||||
password: "password_alice",
|
||||
});
|
||||
expect(result.name).toBe("session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
}
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
||||
}
|
||||
const users = new MockUsersRepository();
|
||||
const auth = new MockAuthenticationService(users);
|
||||
authContainer
|
||||
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
||||
.toConstantValue(users);
|
||||
authContainer
|
||||
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
||||
.toConstantValue(auth);
|
||||
});
|
||||
|
||||
it("translates AuthenticationError → UNAUTHORIZED on missing user", async () => {
|
||||
const caller = authRouter.createCaller({});
|
||||
try {
|
||||
await caller.signIn({ username: "ghost", password: "long-enough" });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("UNAUTHORIZED");
|
||||
}
|
||||
});
|
||||
|
||||
it("translates BAD_REQUEST when zod parse fails at the procedure boundary", async () => {
|
||||
const caller = authRouter.createCaller({});
|
||||
try {
|
||||
await caller.signIn({ username: "ab", password: "x" } as unknown as {
|
||||
username: string;
|
||||
password: string;
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||||
}
|
||||
});
|
||||
});
|
||||
33
packages/auth/src/integrations/api/router.ts
Normal file
33
packages/auth/src/integrations/api/router.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { router } from "@repo/core-shared/trpc/init";
|
||||
|
||||
import { authContainer } from "../../di/container";
|
||||
import { AUTH_SYMBOLS } from "../../di/symbols";
|
||||
|
||||
import { signInInputSchema } from "../../application/use-cases/sign-in.use-case";
|
||||
import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case";
|
||||
import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case";
|
||||
|
||||
import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
|
||||
import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller";
|
||||
import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller";
|
||||
|
||||
import { authProcedure } from "./procedures";
|
||||
|
||||
export const authRouter = router({
|
||||
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
|
||||
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
|
||||
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthRouter = typeof authRouter;
|
||||
49
packages/auth/src/integrations/cms/collections/users.ts
Normal file
49
packages/auth/src/integrations/cms/collections/users.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const users: CollectionConfig = {
|
||||
slug: "users",
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: "email",
|
||||
},
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
subject: { kind: "self", field: "id" },
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "displayName",
|
||||
type: "text",
|
||||
custom: {
|
||||
pii: {
|
||||
category: "identification-username",
|
||||
purpose: ["service-delivery"],
|
||||
exportable: true,
|
||||
restrictable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Admin", value: "admin" },
|
||||
{ label: "Editor", value: "editor" },
|
||||
{ label: "Author", value: "author" },
|
||||
],
|
||||
defaultValue: "author",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "consentState",
|
||||
type: "json",
|
||||
},
|
||||
],
|
||||
};
|
||||
2
packages/auth/src/integrations/cms/index.ts
Normal file
2
packages/auth/src/integrations/cms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { users } from "./collections/users";
|
||||
// <gen:job-tasks>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signInController } from "@/interface-adapters/controllers/sign-in.controller";
|
||||
import { signInUseCase } 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 { InputParseError } from "@/entities/errors/common";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
|
||||
describe("signInController", () => {
|
||||
it("returns a cookie on successful sign-in", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const seedUser = userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_testpassword",
|
||||
});
|
||||
await users.createUser(seedUser);
|
||||
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
const controller = signInController(useCase);
|
||||
|
||||
const result = await controller({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
});
|
||||
expect(result.name).toBe("session");
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws InputParseError on invalid input", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
const controller = signInController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({ username: "ab" } as unknown),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
|
||||
it("throws InputParseError when input is not an object", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signInUseCase(users, auth, new NoopRateLimit());
|
||||
const controller = signInController(useCase);
|
||||
|
||||
await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(
|
||||
InputParseError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
signInInputSchema,
|
||||
type ISignInUseCase,
|
||||
type SignInOutput,
|
||||
} from "../../application/use-cases/sign-in.use-case";
|
||||
|
||||
function presenter(value: SignInOutput) {
|
||||
return value.cookie;
|
||||
}
|
||||
|
||||
export type ISignInController = ReturnType<typeof signInController>;
|
||||
|
||||
export const signInController =
|
||||
(signInUseCase: ISignInUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = signInInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
|
||||
}
|
||||
const result = await signInUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signOutController } from "@/interface-adapters/controllers/sign-out.controller";
|
||||
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
describe("signOutController", () => {
|
||||
it("returns void on successful sign-out", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signOutUseCase(auth);
|
||||
const controller = signOutController(useCase);
|
||||
|
||||
const result = await controller({ sessionId: "any" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws InputParseError on missing sessionId", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signOutUseCase(auth);
|
||||
const controller = signOutController(useCase);
|
||||
|
||||
await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
signOutInputSchema,
|
||||
type ISignOutUseCase,
|
||||
} from "../../application/use-cases/sign-out.use-case";
|
||||
|
||||
export type ISignOutController = ReturnType<typeof signOutController>;
|
||||
|
||||
export const signOutController =
|
||||
(signOutUseCase: ISignOutUseCase) =>
|
||||
async (input: unknown): Promise<void> => {
|
||||
const parsed = signOutInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid sign-out input", { cause: parsed.error });
|
||||
}
|
||||
await signOutUseCase(parsed.data);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
|
||||
import { signUpController } from "@/interface-adapters/controllers/sign-up.controller";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { signUpUseCase } from "@/application/use-cases/sign-up.use-case";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("signUpController", () => {
|
||||
it("returns a cookie on successful sign-up", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signUpUseCase(
|
||||
users,
|
||||
auth,
|
||||
new RecordingEventBus(),
|
||||
undefined,
|
||||
);
|
||||
const controller = signUpController(useCase);
|
||||
|
||||
const result = await controller({
|
||||
username: "carol",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
});
|
||||
expect(result.name).toBe("session");
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws InputParseError when passwords do not match", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signUpUseCase(
|
||||
users,
|
||||
auth,
|
||||
new RecordingEventBus(),
|
||||
undefined,
|
||||
);
|
||||
const controller = signUpController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({
|
||||
username: "dave",
|
||||
password: "secret_password",
|
||||
confirmPassword: "different_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
|
||||
it("throws InputParseError when username is too short", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
await users.createUser(userFactory.build({ username: "alice" }));
|
||||
const useCase = signUpUseCase(
|
||||
users,
|
||||
auth,
|
||||
new RecordingEventBus(),
|
||||
undefined,
|
||||
);
|
||||
const controller = signUpController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({
|
||||
username: "ab",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
signUpInputSchema,
|
||||
type ISignUpUseCase,
|
||||
type SignUpOutput,
|
||||
} from "../../application/use-cases/sign-up.use-case";
|
||||
|
||||
function presenter(value: SignUpOutput) {
|
||||
return value.cookie;
|
||||
}
|
||||
|
||||
export type ISignUpController = ReturnType<typeof signUpController>;
|
||||
|
||||
export const signUpController =
|
||||
(signUpUseCase: ISignUpUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = signUpInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid sign-up input", { cause: parsed.error });
|
||||
}
|
||||
const result = await signUpUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
4
packages/auth/src/ui/index.ts
Normal file
4
packages/auth/src/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Auth has no React Query option builders today (all auth procedures are
|
||||
// mutations). This file is the public UI surface for future components
|
||||
// and queries — extend rather than re-add to root index.ts.
|
||||
export {};
|
||||
5
packages/auth/src/ui/query.ts
Normal file
5
packages/auth/src/ui/query.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// React Query option builders for auth feature procedures.
|
||||
// Sign-in/up/out are mutations — no query options needed.
|
||||
// This file is intentionally minimal; expand if read procedures get added.
|
||||
|
||||
export {};
|
||||
5
packages/auth/stryker.config.json
Normal file
5
packages/auth/stryker.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"_comment": "Auth feature mutation testing config. Extends @repo/core-testing/stryker.base.json (ADR-020 L3). Run with `pnpm mutate --filter @repo/auth`.",
|
||||
"extends": "@repo/core-testing/stryker.base.json"
|
||||
}
|
||||
50
packages/auth/tests/sign-in-flow.feature.test.ts
Normal file
50
packages/auth/tests/sign-in-flow.feature.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Feature-level test: sign-up, then sign-in with the new credentials, then sign-out.
|
||||
// Constructs the full chain via direct injection (no container rebinding).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import { MockUsersRepository } from "../src/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "../src/infrastructure/services/authentication.service.mock";
|
||||
import { signInUseCase } from "../src/application/use-cases/sign-in.use-case";
|
||||
import { signUpUseCase } from "../src/application/use-cases/sign-up.use-case";
|
||||
import { signOutUseCase } from "../src/application/use-cases/sign-out.use-case";
|
||||
import { signInController } from "../src/interface-adapters/controllers/sign-in.controller";
|
||||
import { signUpController } from "../src/interface-adapters/controllers/sign-up.controller";
|
||||
import { signOutController } from "../src/interface-adapters/controllers/sign-out.controller";
|
||||
|
||||
describe("auth feature: sign-up → sign-in → sign-out", () => {
|
||||
it("a new user can sign up, then sign in, then sign out", async () => {
|
||||
// Construct the full chain via direct injection
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
|
||||
const signIn = signInController(
|
||||
signInUseCase(users, auth, new NoopRateLimit()),
|
||||
);
|
||||
const signUp = signUpController(
|
||||
signUpUseCase(users, auth, new RecordingEventBus(), undefined),
|
||||
);
|
||||
const signOut = signOutController(signOutUseCase(auth));
|
||||
|
||||
// signUp returns a cookie (presenter shape)
|
||||
const signUpCookie = await signUp({
|
||||
username: "newperson",
|
||||
password: "verysecret",
|
||||
confirmPassword: "verysecret",
|
||||
});
|
||||
expect(signUpCookie.name).toBe("session");
|
||||
expect(signUpCookie.value).toBeTruthy();
|
||||
|
||||
const signInCookie = await signIn({
|
||||
username: "newperson",
|
||||
password: "verysecret",
|
||||
});
|
||||
expect(signInCookie.name).toBe("session");
|
||||
expect(signInCookie.value).toBeTruthy();
|
||||
|
||||
// signOut takes { sessionId } and returns void
|
||||
const signOutResult = await signOut({ sessionId: signInCookie.value });
|
||||
expect(signOutResult).toBeUndefined();
|
||||
});
|
||||
});
|
||||
14
packages/auth/tsconfig.json
Normal file
14
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "preserve",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/auth/turbo.json
Normal file
4
packages/auth/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["feature"]
|
||||
}
|
||||
38
packages/auth/vitest.config.ts
Normal file
38
packages/auth/vitest.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
import {
|
||||
DEFAULT_COVERAGE_BANDS,
|
||||
vitestThresholdsFromBands,
|
||||
} from "@repo/core-shared/conformance/coverage";
|
||||
|
||||
// Coverage thresholds derived from DEFAULT_COVERAGE_BANDS via the shared
|
||||
// helper — one source of truth for the conventional band shape across all
|
||||
// features (ADR-020). The feature.manifest.ts `coverage.bands` section also
|
||||
// declares these for boot-time `assertFeatureConformance` (which reads the
|
||||
// manifest directly, not the vitest config). For features that need
|
||||
// non-default bands, override here AND in the manifest, then add a drift
|
||||
// test in core-shared/conformance/.
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: [
|
||||
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
|
||||
"src/di/bind-production.ts",
|
||||
// Pure TypeScript interface files — not executable
|
||||
"src/application/repositories/**",
|
||||
"src/application/services/**",
|
||||
// Payload CMS collection config — declarative data, tested via Payload integration
|
||||
"src/integrations/cms/**",
|
||||
// Pure type-alias file — no executable code
|
||||
"src/entities/cookie.ts",
|
||||
// React Query option builders — integration-tested in apps
|
||||
"src/ui/**",
|
||||
],
|
||||
thresholds: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
138
packages/blog/AGENTS.md
Normal file
138
packages/blog/AGENTS.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# AGENTS.md — blog
|
||||
|
||||
Articles collection + content use cases (get articles, get article by slug, create article). Provides the Articles Payload collection and tRPC procedures for content management and retrieval.
|
||||
|
||||
## Overview
|
||||
|
||||
`@repo/blog` owns: Article domain model, blog-scoped errors, the `IArticlesRepository` interface, three use cases, three controllers, a real Payload-backed repository, and the tRPC `blogRouter`. Query builders live in `./ui`.
|
||||
|
||||
## Layer responsibilities
|
||||
|
||||
| Layer | Key files |
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **entities/models** | `article.ts` — Zod schema + `Article`, `ArticleStatus` types |
|
||||
| **entities/errors** | `article.ts` (ArticleNotFoundError), `common.ts` (InputParseError) |
|
||||
| **application/use-cases** | `get-articles.use-case.ts`, `create-article.use-case.ts`, `get-article-by-slug.use-case.ts` — factory functions + exported schemas |
|
||||
| **application/repositories** | `articles.repository.interface.ts` — `IArticlesRepository` |
|
||||
| **infrastructure/repositories** | `articles.repository.ts` (real Payload-backed), `articles.repository.mock.ts` (in-memory) |
|
||||
| **interface-adapters/controllers** | `get-articles.controller.ts`, `create-article.controller.ts`, `get-article-by-slug.controller.ts` — one file per use case |
|
||||
| **di** | `symbols.ts` (BLOG_SYMBOLS), `module.ts`, `container.ts`, `bind-production.ts` |
|
||||
| **integrations/api** | `procedures.ts` (blogProcedure), `router.ts` (blogRouter) |
|
||||
| **integrations/cms** | `collections/articles.ts` — Payload Articles CollectionConfig |
|
||||
| **ui** | `src/ui/index.ts` — re-exports `articleBySlugQuery` and `listArticlesQuery` |
|
||||
|
||||
## Public exports
|
||||
|
||||
| Subpath | Contents |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `.` | `Article`, `ArticleStatus` types; `ArticleNotFoundError`, `InputParseError`; all use-case schemas + input/output types + `IXUseCase` aliases; `IXController` type aliases; `BlogRouter` type |
|
||||
| `./ui` | `articleBySlugQuery`, `listArticlesQuery` — React Query option builders |
|
||||
| `./api` | `blogRouter` (tRPC router) |
|
||||
| `./cms` | Payload Articles collection definition |
|
||||
| `./di/bind-production` | `bindProductionBlog(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedBlog(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
|
||||
## Use-case + controller patterns
|
||||
|
||||
See `CLAUDE.md` Key Conventions and `docs/architecture/overview.md` for the canonical factory templates.
|
||||
|
||||
### Use cases
|
||||
|
||||
| Use case | Input schema | Output schema | Notes |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------- |
|
||||
| `getArticlesUseCase` | `getArticlesInputSchema` — `{ status? }` (status narrowed to `articleStatusSchema`) | `getArticlesOutputSchema` — `z.array(articleSchema)` | Returns all articles (optionally filtered) |
|
||||
| `createArticleUseCase` | `createArticleInputSchema` — `{ title, slug, content, ... }` | `createArticleOutputSchema` — `articleSchema` | Creates and persists an article |
|
||||
| `getArticleBySlugUseCase` | `getArticleBySlugInputSchema` — `{ slug }` | `getArticleBySlugOutputSchema` — `articleSchema` | Throws `ArticleNotFoundError` when slug not found |
|
||||
|
||||
### Controllers
|
||||
|
||||
All three controllers use identity presenters — `function presenter(value: XOutput) { return value; }` — and return `Promise<ReturnType<typeof presenter>>`. All accept `unknown` input and `safeParse` with the use-case's `xInputSchema`, throwing `InputParseError` on failure.
|
||||
|
||||
## Errors → tRPC codes
|
||||
|
||||
| Error class | tRPC code | Thrown by |
|
||||
| ---------------------- | ------------- | ------------------------------- |
|
||||
| `InputParseError` | `BAD_REQUEST` | controllers (safeParse failure) |
|
||||
| `ArticleNotFoundError` | `NOT_FOUND` | `getArticleBySlugUseCase` |
|
||||
|
||||
Defined in `src/integrations/api/procedures.ts` via `blogProcedure = t.procedure.use(defineErrorMiddleware([...]))`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Factories:** `src/__factories__/article.factory.ts`
|
||||
- **Contract suite:** `src/__contracts__/articles-repository.contract.ts` — runs against mock and real `ArticlesRepository`
|
||||
- **Unit tests:** colocated `*.test.ts` next to each source file
|
||||
- **Feature integration:** `tests/articles.feature.test.ts` — full slice: tRPC caller → controller → use case → mock repo
|
||||
- **R25** (output validation): each of the three use-case test files has a test that injects a malformed repository mock and asserts `.rejects.toBeInstanceOf(ZodError)`.
|
||||
- **R26** (router error mapping): `router.test.ts` has `NOT_FOUND` on `articleBySlug` with unknown slug, and `BAD_REQUEST` on empty input.
|
||||
- **R27/R28** (presenter shape): all three controllers use identity presenters — no reshape test obligation; the returned value equals the use-case output.
|
||||
|
||||
```bash
|
||||
pnpm test --filter @repo/blog
|
||||
pnpm test --filter @repo/blog -- --watch
|
||||
```
|
||||
|
||||
See `docs/guides/tdd-workflow.md` for the full cycle.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
src/
|
||||
entities/
|
||||
models/
|
||||
article.ts
|
||||
errors/
|
||||
article.ts # ArticleNotFoundError
|
||||
common.ts # InputParseError
|
||||
application/
|
||||
repositories/
|
||||
articles.repository.interface.ts
|
||||
use-cases/
|
||||
get-articles.use-case.ts
|
||||
create-article.use-case.ts
|
||||
get-article-by-slug.use-case.ts
|
||||
infrastructure/
|
||||
repositories/
|
||||
articles.repository.ts # real Payload-backed
|
||||
articles.repository.mock.ts
|
||||
interface-adapters/
|
||||
controllers/
|
||||
get-articles.controller.ts
|
||||
create-article.controller.ts
|
||||
get-article-by-slug.controller.ts
|
||||
integrations/
|
||||
api/
|
||||
procedures.ts # blogProcedure
|
||||
router.ts # blogRouter
|
||||
cms/
|
||||
collections/
|
||||
articles.ts
|
||||
index.ts
|
||||
di/
|
||||
symbols.ts # BLOG_SYMBOLS
|
||||
module.ts
|
||||
container.ts
|
||||
bind-production.ts
|
||||
ui/
|
||||
index.ts # articleBySlugQuery, listArticlesQuery
|
||||
query.ts
|
||||
index.ts
|
||||
__factories__/
|
||||
article.factory.ts
|
||||
__contracts__/
|
||||
articles-repository.contract.ts
|
||||
tests/
|
||||
articles.feature.test.ts
|
||||
```
|
||||
|
||||
## What it must NOT import
|
||||
|
||||
- Any other feature package (`@repo/auth`, `@repo/media`, etc.)
|
||||
- Any app package
|
||||
- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui` directly; only `@repo/core-shared`
|
||||
> Note: `@repo/core-trpc` and `@repo/core-ui` are optional packages scaffolded via `pnpm turbo gen core-package trpc` / `ui`. If not present, these constraints still apply to any future installation.
|
||||
|
||||
## Cross-links
|
||||
|
||||
- ADR-012 (`docs/decisions/adr-012-feature-conventions.md`) — factory-style use cases, per-use-case controllers, file-naming conventions
|
||||
- ADR-013 (`docs/decisions/adr-013-input-output-unification.md`) — schemas-in-use-case, presenter, `./ui` subpath, error middleware
|
||||
11
packages/blog/CHANGELOG.md
Normal file
11
packages/blog/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Changelog — @repo/blog
|
||||
|
||||
All notable changes to the `blog` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md).
|
||||
|
||||
## 0.1.0 (2026-05-13)
|
||||
|
||||
### Initial baseline
|
||||
|
||||
The `blog` feature established at v0.1.0 alongside the hybrid versioning rollout (ADR-021). The feature has been stable since the template-reset cleanup; this is the first formally versioned baseline.
|
||||
|
||||
Future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/blog/**` since the last release.
|
||||
3
packages/blog/eslint.config.js
Normal file
3
packages/blog/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
41
packages/blog/package.json
Normal file
41
packages/blog/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@repo/blog",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cms": "./src/integrations/cms/index.ts",
|
||||
"./api": "./src/integrations/api/router.ts",
|
||||
"./di/bind-production": "./src/di/bind-production.ts",
|
||||
"./di/bind-dev-seed": "./src/di/bind-dev-seed.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/core-trpc": "workspace:^",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"inversify": "^6.2.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
178
packages/blog/src/__contracts__/articles-repository.contract.ts
Normal file
178
packages/blog/src/__contracts__/articles-repository.contract.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { it, expect, beforeEach, describe } from "vitest";
|
||||
import { defineContractSuite } from "@repo/core-testing/contract";
|
||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||
import { articleFactory } from "../__factories__/article.factory";
|
||||
|
||||
export const articlesRepositoryContract =
|
||||
defineContractSuite<IArticlesRepository>(
|
||||
"IArticlesRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IArticlesRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
articleFactory.reset();
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
// --- createArticle ---
|
||||
|
||||
it("createArticle returns an article with an id and the correct fields", async () => {
|
||||
const seed = articleFactory.build({ title: "Hello World" });
|
||||
const created = await repo.createArticle(seed);
|
||||
// Implementations may assign their own id (e.g. Payload), so we only
|
||||
// verify the id is a non-empty string and the other fields match.
|
||||
expect(typeof created.id).toBe("string");
|
||||
expect(created.id.length).toBeGreaterThan(0);
|
||||
expect(created.title).toBe("Hello World");
|
||||
expect(created.slug).toBe(seed.slug);
|
||||
expect(created.status).toBe(seed.status);
|
||||
expect(created.authorId).toBe(seed.authorId);
|
||||
});
|
||||
|
||||
// --- getArticle ---
|
||||
|
||||
it("createArticle then getArticle returns it by the returned id", async () => {
|
||||
const seed = articleFactory.build();
|
||||
const created = await repo.createArticle(seed);
|
||||
// Use the id returned by createArticle (Payload may differ from seed.id)
|
||||
const result = await repo.getArticle(created.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(created.id);
|
||||
expect(result?.slug).toBe(seed.slug);
|
||||
});
|
||||
|
||||
it("getArticle returns undefined for missing id", async () => {
|
||||
expect(await repo.getArticle("does-not-exist")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getArticleBySlug ---
|
||||
|
||||
it("createArticle then getArticleBySlug returns it by slug", async () => {
|
||||
const seed = articleFactory.build({ slug: "my-slug" });
|
||||
const created = await repo.createArticle(seed);
|
||||
const result = await repo.getArticleBySlug("my-slug");
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(created.id);
|
||||
expect(result?.slug).toBe("my-slug");
|
||||
});
|
||||
|
||||
it("getArticleBySlug returns undefined for missing slug", async () => {
|
||||
expect(await repo.getArticleBySlug("does-not-exist")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getArticles ---
|
||||
|
||||
it("getArticles returns empty array when no articles", async () => {
|
||||
const list = await repo.getArticles();
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getArticles returns all articles when no filter", async () => {
|
||||
await repo.createArticle(articleFactory.build());
|
||||
await repo.createArticle(articleFactory.build());
|
||||
const list = await repo.getArticles();
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("getArticles filters by status", async () => {
|
||||
await repo.createArticle(articleFactory.build({ status: "draft" }));
|
||||
await repo.createArticle(articleFactory.build({ status: "published" }));
|
||||
const drafts = await repo.getArticles({ status: "draft" });
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0]?.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("getArticles filters by authorId", async () => {
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ authorId: "author-a" }),
|
||||
);
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ authorId: "author-b" }),
|
||||
);
|
||||
const result = await repo.getArticles({ authorId: "author-a" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.authorId).toBe("author-a");
|
||||
});
|
||||
|
||||
// --- updateArticle ---
|
||||
|
||||
it("updateArticle changes fields and returns updated article", async () => {
|
||||
const seed = articleFactory.build({ status: "draft" });
|
||||
const created = await repo.createArticle(seed);
|
||||
// Use the returned id for the update lookup
|
||||
const updated = await repo.updateArticle(created.id, {
|
||||
status: "published",
|
||||
});
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.id).toBe(created.id);
|
||||
expect(updated?.status).toBe("published");
|
||||
});
|
||||
|
||||
it("updateArticle returns undefined for missing id", async () => {
|
||||
const result = await repo.updateArticle("no-such-id", {
|
||||
title: "new",
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("span emission", () => {
|
||||
it("getArticles emits articles.getArticles span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getArticles({ limit: 5 });
|
||||
const span = tracer.findSpan("articles.getArticles");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.limit).toBe(5);
|
||||
});
|
||||
|
||||
it("getArticle emits articles.getArticle span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getArticle("nonexistent");
|
||||
const span = tracer.findSpan("articles.getArticle");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
|
||||
it("getArticleBySlug emits articles.getArticleBySlug span with slug attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getArticleBySlug("nonexistent");
|
||||
const span = tracer.findSpan("articles.getArticleBySlug");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.slug).toBe("nonexistent");
|
||||
});
|
||||
|
||||
it("createArticle emits articles.createArticle span", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
const seed = articleFactory.build();
|
||||
await repo.createArticle(seed);
|
||||
const span = tracer.findSpan("articles.createArticle");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.slug).toBe(seed.slug);
|
||||
});
|
||||
|
||||
it("updateArticle emits articles.updateArticle span", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
const seed = articleFactory.build();
|
||||
const created = await repo.createArticle(seed);
|
||||
await repo.updateArticle(created.id, { title: "Updated" });
|
||||
const span = tracer.findSpan("articles.updateArticle");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe(created.id);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
27
packages/blog/src/__factories__/article.factory.test.ts
Normal file
27
packages/blog/src/__factories__/article.factory.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
describe("articleFactory", () => {
|
||||
beforeEach(() => articleFactory.reset());
|
||||
|
||||
it("returns an Article with stable defaults", () => {
|
||||
const a = articleFactory.build();
|
||||
expect(a.title).toBe("Article 1");
|
||||
expect(a.slug).toBe("article-1");
|
||||
expect(a.status).toBe("draft");
|
||||
expect(a.createdAt).toEqual(new Date("2026-01-01T00:00:00Z"));
|
||||
});
|
||||
|
||||
it("applies overrides", () => {
|
||||
const a = articleFactory.build({ status: "published", title: "X" });
|
||||
expect(a.status).toBe("published");
|
||||
expect(a.title).toBe("X");
|
||||
});
|
||||
|
||||
it("increments sequence per build", () => {
|
||||
const a = articleFactory.build();
|
||||
const b = articleFactory.build();
|
||||
expect(a.id).toBe("article-1");
|
||||
expect(b.id).toBe("article-2");
|
||||
});
|
||||
});
|
||||
13
packages/blog/src/__factories__/article.factory.ts
Normal file
13
packages/blog/src/__factories__/article.factory.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineFactory } from "@repo/core-testing/factory";
|
||||
import type { Article } from "../entities/models/article";
|
||||
|
||||
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
|
||||
id: `article-${sequence}`,
|
||||
title: `Article ${sequence}`,
|
||||
slug: `article-${sequence}`,
|
||||
content: null,
|
||||
status: "draft",
|
||||
authorId: "user-1",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
}));
|
||||
1
packages/blog/src/__factories__/index.ts
Normal file
1
packages/blog/src/__factories__/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { articleFactory } from "./article.factory";
|
||||
35
packages/blog/src/__seeds__/dev.ts
Normal file
35
packages/blog/src/__seeds__/dev.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { articleFactory } from "../__factories__/article.factory";
|
||||
import type { Article } from "../entities/models/article";
|
||||
|
||||
/**
|
||||
* Realistic blog seed for dev mode + storybook stories.
|
||||
*
|
||||
* Built from `articleFactory` so factory defaults take care of the boring
|
||||
* fields (createdAt, updatedAt, content, authorId) and we only override what
|
||||
* makes the data look like a populated database.
|
||||
*
|
||||
* Lazily produced so importing this module is side-effect-free — the factory's
|
||||
* sequence counter only advances when a binder calls `buildDevArticles()`.
|
||||
*/
|
||||
export function buildDevArticles(): Article[] {
|
||||
return [
|
||||
articleFactory.build({
|
||||
id: "welcome",
|
||||
slug: "welcome",
|
||||
title: "Welcome to the blog",
|
||||
status: "published",
|
||||
}),
|
||||
articleFactory.build({
|
||||
id: "vertical-feature-architecture",
|
||||
slug: "vertical-feature-architecture",
|
||||
title: "Why vertical-feature packages",
|
||||
status: "published",
|
||||
}),
|
||||
articleFactory.build({
|
||||
id: "wip-post",
|
||||
slug: "work-in-progress",
|
||||
title: "A draft we haven't shipped yet",
|
||||
status: "draft",
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export interface IArticlesRepository {
|
||||
getArticle(id: string): Promise<Article | undefined>;
|
||||
getArticleBySlug(slug: string): Promise<Article | undefined>;
|
||||
getArticles(options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]>;
|
||||
createArticle(input: Article): Promise<Article>;
|
||||
updateArticle(
|
||||
id: string,
|
||||
input: Partial<Article>,
|
||||
): Promise<Article | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
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";
|
||||
|
||||
describe("createArticleUseCase", () => {
|
||||
it("creates an article in draft status with auto-generated slug", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = createArticleUseCase(repo);
|
||||
|
||||
const result = await useCase({
|
||||
title: "Hello World",
|
||||
content: "body",
|
||||
authorId: "u1",
|
||||
});
|
||||
expect(result.title).toBe("Hello World");
|
||||
expect(result.slug).toBe("hello-world");
|
||||
expect(result.status).toBe("draft");
|
||||
expect(result.id).toBeTruthy();
|
||||
|
||||
const stored = await repo.getArticle(result.id);
|
||||
expect(stored).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses provided slug when supplied", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = createArticleUseCase(repo);
|
||||
|
||||
const result = await useCase({
|
||||
title: "Whatever",
|
||||
content: "body",
|
||||
authorId: "u1",
|
||||
slug: "custom-slug",
|
||||
});
|
||||
expect(result.slug).toBe("custom-slug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createArticleUseCase output validation", () => {
|
||||
it("throws when repository returns a malformed article", async () => {
|
||||
const repo = {
|
||||
createArticle: async () => ({ id: 1 }) as unknown as never,
|
||||
} as unknown as IArticlesRepository;
|
||||
const useCase = createArticleUseCase(repo);
|
||||
await expect(
|
||||
useCase({ title: "X", authorId: "u1" }),
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that accepts a valid article shape", () => {
|
||||
expect(createArticleOutputSchema).toBeDefined();
|
||||
const result = createArticleOutputSchema.safeParse({
|
||||
id: "a1",
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
content: null,
|
||||
status: "draft",
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { articleSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const createArticleInputSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.unknown().optional(),
|
||||
authorId: z.string(),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type CreateArticleInput = z.infer<typeof createArticleInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const createArticleOutputSchema = articleSchema;
|
||||
export type CreateArticleOutput = z.infer<typeof createArticleOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
|
||||
|
||||
export const createArticleUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: CreateArticleInput): Promise<CreateArticleOutput> => {
|
||||
const now = new Date();
|
||||
const article = {
|
||||
id: crypto.randomUUID(),
|
||||
title: input.title,
|
||||
slug: input.slug ?? generateSlug(input.title),
|
||||
content: input.content ?? null,
|
||||
status: "draft" as const,
|
||||
authorId: input.authorId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const result = await articlesRepository.createArticle(article);
|
||||
return createArticleOutputSchema.parse(result);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
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";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
describe("getArticleBySlugUseCase", () => {
|
||||
it("returns the article when slug exists", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const seed = articleFactory.build({ slug: "test-slug" });
|
||||
await repo.createArticle(seed);
|
||||
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const result = await useCase({ slug: "test-slug" });
|
||||
|
||||
expect(result?.slug).toBe("test-slug");
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArticleBySlugUseCase output validation", () => {
|
||||
it("throws when repository returns a malformed article", async () => {
|
||||
const repo = {
|
||||
getArticleBySlug: async () => ({ id: 123 }) as unknown as never,
|
||||
} as unknown as IArticlesRepository;
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
await expect(useCase({ slug: "test" })).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that accepts a valid article shape", () => {
|
||||
expect(getArticleBySlugOutputSchema).toBeDefined();
|
||||
const result = getArticleBySlugOutputSchema.safeParse({
|
||||
id: "a1",
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
content: null,
|
||||
status: "draft",
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||||
import { articleSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const getArticleBySlugInputSchema = z
|
||||
.object({ slug: z.string().min(1) })
|
||||
.strict();
|
||||
export type GetArticleBySlugInput = z.infer<typeof getArticleBySlugInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const getArticleBySlugOutputSchema = articleSchema;
|
||||
export type GetArticleBySlugOutput = z.infer<typeof getArticleBySlugOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;
|
||||
|
||||
export const getArticleBySlugUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: GetArticleBySlugInput): Promise<GetArticleBySlugOutput> => {
|
||||
const article = await articlesRepository.getArticleBySlug(input.slug);
|
||||
if (!article) {
|
||||
throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`);
|
||||
}
|
||||
return getArticleBySlugOutputSchema.parse(article);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
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";
|
||||
|
||||
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" }),
|
||||
);
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const result = await useCase({});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("1");
|
||||
});
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const result = await useCase({ status: "published" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
(repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 });
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
await expect(useCase({})).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors Article[]", () => {
|
||||
expect(getArticlesOutputSchema).toBeDefined();
|
||||
expect(getArticlesOutputSchema.safeParse([]).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { articleSchema, articleStatusSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const getArticlesInputSchema = z
|
||||
.object({
|
||||
status: articleStatusSchema.optional(),
|
||||
authorId: z.string().optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const getArticlesOutputSchema = z.array(articleSchema);
|
||||
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||||
|
||||
export const getArticlesUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
|
||||
const result = await articlesRepository.getArticles(input);
|
||||
return getArticlesOutputSchema.parse(result);
|
||||
};
|
||||
76
packages/blog/src/di/bind-dev-seed.test.ts
Normal file
76
packages/blog/src/di/bind-dev-seed.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||
import { bindDevSeedBlog } from "@/di/bind-dev-seed";
|
||||
import { blogContainer } from "@/di/container";
|
||||
import { BLOG_SYMBOLS } from "@/di/symbols";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
describe("bindDevSeedBlog", () => {
|
||||
const tracer = new NoopTracer();
|
||||
const logger = new NoopLogger();
|
||||
|
||||
// Each test starts from the default empty-mock binding and tears down
|
||||
// afterwards so the global blogContainer state stays clean for siblings.
|
||||
beforeEach(() => {
|
||||
for (const sym of Object.values(BLOG_SYMBOLS)) {
|
||||
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||
}
|
||||
blogContainer
|
||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.to(MockArticlesRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const sym of Object.values(BLOG_SYMBOLS)) {
|
||||
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||
}
|
||||
blogContainer
|
||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.to(MockArticlesRepository);
|
||||
});
|
||||
|
||||
it("populates the repository with the dev articles", async () => {
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const all = await repo.getArticles();
|
||||
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("seeds the welcome article reachable by slug", async () => {
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const welcome = await repo.getArticleBySlug("welcome");
|
||||
|
||||
expect(welcome).toBeDefined();
|
||||
expect(welcome?.title).toBe("Welcome to the blog");
|
||||
expect(welcome?.status).toBe("published");
|
||||
});
|
||||
|
||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const before = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const beforeCount = (await before.getArticles()).length;
|
||||
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const after = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const afterCount = (await after.getArticles()).length;
|
||||
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
// It's a fresh instance — not the previous one.
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
168
packages/blog/src/di/bind-dev-seed.ts
Normal file
168
packages/blog/src/di/bind-dev-seed.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { blogManifest } from "../feature.manifest";
|
||||
import { blogContainer } from "./container";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock";
|
||||
import { buildDevArticles } from "../__seeds__/dev";
|
||||
import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case";
|
||||
import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import { createArticleUseCase } from "../application/use-cases/create-article.use-case";
|
||||
import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller";
|
||||
import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
|
||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||
|
||||
/**
|
||||
* Replace the default empty mock with a populated one for dev mode + storybook.
|
||||
*
|
||||
* Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with
|
||||
* `bindProductionBlog(config)`. Tests must NOT call this — they construct
|
||||
* `new MockArticlesRepository()` directly and seed via factories per-test.
|
||||
*
|
||||
* Idempotent: safe to call multiple times; each call rebuilds a fresh
|
||||
* populated repo and rebinds the symbol.
|
||||
*/
|
||||
export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
||||
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
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);
|
||||
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
||||
}
|
||||
const repo = new MockArticlesRepository(tracer, logger);
|
||||
for (const article of buildDevArticles()) {
|
||||
await repo.createArticle(article);
|
||||
}
|
||||
blogContainer
|
||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.toConstantValue(repo);
|
||||
|
||||
// Use cases
|
||||
const wrappedGetArticles = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
factory: getArticlesUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticles",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedGetArticleBySlug = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
factory: getArticleBySlugUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticleBySlug",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedCreateArticle = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
factory: createArticleUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "createArticle",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
for (const sym of [
|
||||
BLOG_SYMBOLS.IGetArticlesController,
|
||||
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||||
BLOG_SYMBOLS.ICreateArticleController,
|
||||
]) {
|
||||
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||
}
|
||||
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticlesController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
|
||||
getArticlesController(wrappedGetArticles),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticleBySlugController).toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{
|
||||
feature: "blog",
|
||||
layer: "controller",
|
||||
name: "blog.getArticleBySlug",
|
||||
},
|
||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.ICreateArticleController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.createArticle", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.createArticle" },
|
||||
createArticleController(wrappedCreateArticle),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check (dev-seed mode).
|
||||
assertFeatureConformance(
|
||||
blogContainer,
|
||||
blogManifest,
|
||||
{
|
||||
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
17
packages/blog/src/di/bind-production.smoke.test.ts
Normal file
17
packages/blog/src/di/bind-production.smoke.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import "reflect-metadata";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { bindProductionBlog } from "@/di/bind-production";
|
||||
|
||||
describe("bindProductionBlog — boot-time conformance", () => {
|
||||
it("binds every manifest use case through withSpan + withCapture", () => {
|
||||
expect(() =>
|
||||
bindProductionBlog({
|
||||
config: {} as SanitizedConfig,
|
||||
tracer: new NoopTracer(),
|
||||
logger: new NoopLogger(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
155
packages/blog/src/di/bind-production.ts
Normal file
155
packages/blog/src/di/bind-production.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { blogContainer } from "./container";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
import { blogManifest } from "../feature.manifest";
|
||||
import { ArticlesRepository } from "../infrastructure/repositories/articles.repository";
|
||||
import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case";
|
||||
import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import { createArticleUseCase } from "../application/use-cases/create-article.use-case";
|
||||
import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller";
|
||||
import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
|
||||
|
||||
export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
||||
ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
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);
|
||||
|
||||
// Real repository
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
||||
}
|
||||
const repo = new ArticlesRepository(config, tracer, logger);
|
||||
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
|
||||
|
||||
// Use cases
|
||||
const wrappedGetArticles = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
factory: getArticlesUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticles",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedGetArticleBySlug = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
factory: getArticleBySlugUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticleBySlug",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedCreateArticle = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
factory: createArticleUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "createArticle",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Controllers — wrapped with span at bind time
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IGetArticlesController);
|
||||
}
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticleBySlugController)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IGetArticleBySlugController);
|
||||
}
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleController)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleController);
|
||||
}
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticlesController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
|
||||
getArticlesController(wrappedGetArticles),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticleBySlugController).toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{
|
||||
feature: "blog",
|
||||
layer: "controller",
|
||||
name: "blog.getArticleBySlug",
|
||||
},
|
||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.ICreateArticleController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.createArticle", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.createArticle" },
|
||||
createArticleController(wrappedCreateArticle),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check.
|
||||
assertFeatureConformance(
|
||||
blogContainer,
|
||||
blogManifest,
|
||||
{
|
||||
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
39
packages/blog/src/di/container.test.ts
Normal file
39
packages/blog/src/di/container.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { blogContainer } from "./container";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
import { BlogModule } from "./module";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
describe("blogContainer", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("resolves IArticlesRepository to MockArticlesRepository by default binding", () => {
|
||||
const repo = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
expect(repo).toBeInstanceOf(MockArticlesRepository);
|
||||
});
|
||||
|
||||
it("resolves IGetArticlesController from the container", () => {
|
||||
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticlesController);
|
||||
expect(typeof ctrl).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves ICreateArticleController from the container", () => {
|
||||
const ctrl = blogContainer.get(BLOG_SYMBOLS.ICreateArticleController);
|
||||
expect(typeof ctrl).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves IGetArticleBySlugController from the container", () => {
|
||||
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticleBySlugController);
|
||||
expect(typeof ctrl).toBe("function");
|
||||
});
|
||||
});
|
||||
6
packages/blog/src/di/container.ts
Normal file
6
packages/blog/src/di/container.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
import { BlogModule } from "./module";
|
||||
|
||||
export const blogContainer = new Container({ defaultScope: "Singleton" });
|
||||
blogContainer.load(BlogModule);
|
||||
69
packages/blog/src/di/module.ts
Normal file
69
packages/blog/src/di/module.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ContainerModule, type interfaces } from "inversify";
|
||||
|
||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock";
|
||||
import {
|
||||
getArticlesUseCase,
|
||||
type IGetArticlesUseCase,
|
||||
} from "../application/use-cases/get-articles.use-case";
|
||||
import {
|
||||
createArticleUseCase,
|
||||
type ICreateArticleUseCase,
|
||||
} from "../application/use-cases/create-article.use-case";
|
||||
import {
|
||||
getArticleBySlugUseCase,
|
||||
type IGetArticleBySlugUseCase,
|
||||
} from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import {
|
||||
getArticlesController,
|
||||
type IGetArticlesController,
|
||||
} from "../interface-adapters/controllers/get-articles.controller";
|
||||
import {
|
||||
createArticleController,
|
||||
type ICreateArticleController,
|
||||
} from "../interface-adapters/controllers/create-article.controller";
|
||||
import {
|
||||
getArticleBySlugController,
|
||||
type IGetArticleBySlugController,
|
||||
} from "../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
|
||||
export const BlogModule = new ContainerModule((bind: interfaces.Bind) => {
|
||||
bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository);
|
||||
|
||||
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue((ctx) =>
|
||||
getArticlesUseCase(
|
||||
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ICreateArticleUseCase>(BLOG_SYMBOLS.ICreateArticleUseCase).toDynamicValue((ctx) =>
|
||||
createArticleUseCase(
|
||||
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||
),
|
||||
);
|
||||
|
||||
bind<IGetArticleBySlugUseCase>(BLOG_SYMBOLS.IGetArticleBySlugUseCase).toDynamicValue((ctx) =>
|
||||
getArticleBySlugUseCase(
|
||||
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||
),
|
||||
);
|
||||
|
||||
bind<IGetArticlesController>(BLOG_SYMBOLS.IGetArticlesController).toDynamicValue((ctx) =>
|
||||
getArticlesController(
|
||||
ctx.container.get<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ICreateArticleController>(BLOG_SYMBOLS.ICreateArticleController).toDynamicValue((ctx) =>
|
||||
createArticleController(
|
||||
ctx.container.get<ICreateArticleUseCase>(BLOG_SYMBOLS.ICreateArticleUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
bind<IGetArticleBySlugController>(BLOG_SYMBOLS.IGetArticleBySlugController).toDynamicValue((ctx) =>
|
||||
getArticleBySlugController(
|
||||
ctx.container.get<IGetArticleBySlugUseCase>(BLOG_SYMBOLS.IGetArticleBySlugUseCase),
|
||||
),
|
||||
);
|
||||
});
|
||||
14
packages/blog/src/di/symbols.ts
Normal file
14
packages/blog/src/di/symbols.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const BLOG_SYMBOLS = {
|
||||
IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
|
||||
// Use cases
|
||||
IGetArticlesUseCase: Symbol.for("blog:IGetArticlesUseCase"),
|
||||
ICreateArticleUseCase: Symbol.for("blog:ICreateArticleUseCase"),
|
||||
IGetArticleBySlugUseCase: Symbol.for("blog:IGetArticleBySlugUseCase"),
|
||||
// Controllers
|
||||
IGetArticlesController: Symbol.for("blog:IGetArticlesController"),
|
||||
ICreateArticleController: Symbol.for("blog:ICreateArticleController"),
|
||||
IGetArticleBySlugController: Symbol.for("blog:IGetArticleBySlugController"),
|
||||
// <gen:event-handler-symbols>
|
||||
// <gen:job-symbols>
|
||||
// <gen:realtime-handler-symbols>
|
||||
} as const;
|
||||
6
packages/blog/src/entities/errors/article.ts
Normal file
6
packages/blog/src/entities/errors/article.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class ArticleNotFoundError extends Error {
|
||||
constructor(message = "Article not found", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ArticleNotFoundError";
|
||||
}
|
||||
}
|
||||
6
packages/blog/src/entities/errors/common.ts
Normal file
6
packages/blog/src/entities/errors/common.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
24
packages/blog/src/entities/errors/errors.test.ts
Normal file
24
packages/blog/src/entities/errors/errors.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ArticleNotFoundError } from "./article";
|
||||
import { InputParseError } from "./common";
|
||||
|
||||
describe("ArticleNotFoundError", () => {
|
||||
it("uses the default message when none is given", () => {
|
||||
const err = new ArticleNotFoundError();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("Article not found");
|
||||
});
|
||||
|
||||
it("uses a custom message when provided", () => {
|
||||
const err = new ArticleNotFoundError("could not find article abc");
|
||||
expect(err.message).toBe("could not find article abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputParseError", () => {
|
||||
it("is an instance of Error with the given message", () => {
|
||||
const err = new InputParseError("invalid input");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("invalid input");
|
||||
});
|
||||
});
|
||||
85
packages/blog/src/entities/models/article.test.ts
Normal file
85
packages/blog/src/entities/models/article.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { articleSchema, articleStatusSchema, type Article } from "./article";
|
||||
|
||||
describe("articleSchema", () => {
|
||||
it("accepts a minimal valid article with default status", () => {
|
||||
const result = articleSchema.parse({
|
||||
id: "abc",
|
||||
title: "Hello",
|
||||
slug: "hello",
|
||||
content: { type: "doc", children: [] },
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("accepts unknown rich-text content", () => {
|
||||
const result = articleSchema.parse({
|
||||
id: "abc",
|
||||
title: "Hello",
|
||||
slug: "hello",
|
||||
content: "any string is also fine",
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.content).toBe("any string is also fine");
|
||||
});
|
||||
|
||||
it("rejects empty title", () => {
|
||||
expect(() =>
|
||||
articleSchema.parse({
|
||||
id: "a",
|
||||
title: "",
|
||||
slug: "s",
|
||||
content: null,
|
||||
authorId: "u",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects title over 255 chars", () => {
|
||||
expect(() =>
|
||||
articleSchema.parse({
|
||||
id: "a",
|
||||
title: "x".repeat(256),
|
||||
slug: "s",
|
||||
content: null,
|
||||
authorId: "u",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("articleStatusSchema", () => {
|
||||
it("accepts 'draft' and 'published'", () => {
|
||||
expect(articleStatusSchema.parse("draft")).toBe("draft");
|
||||
expect(articleStatusSchema.parse("published")).toBe("published");
|
||||
});
|
||||
|
||||
it("rejects unknown status", () => {
|
||||
expect(() => articleStatusSchema.parse("archived")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Article type", () => {
|
||||
it("widens content to unknown", () => {
|
||||
const _example: Article = {
|
||||
id: "x",
|
||||
title: "t",
|
||||
slug: "s",
|
||||
content: { whatever: true },
|
||||
status: "draft",
|
||||
authorId: "u",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
expect(_example).toBeDefined();
|
||||
});
|
||||
});
|
||||
17
packages/blog/src/entities/models/article.ts
Normal file
17
packages/blog/src/entities/models/article.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const articleStatusSchema = z.enum(["draft", "published"]);
|
||||
|
||||
export const articleSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(255),
|
||||
content: z.unknown(),
|
||||
status: articleStatusSchema.default("draft"),
|
||||
authorId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type Article = z.infer<typeof articleSchema>;
|
||||
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
|
||||
52
packages/blog/src/feature.manifest.ts
Normal file
52
packages/blog/src/feature.manifest.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* The blog feature's conformance manifest.
|
||||
*/
|
||||
export const blogManifest = defineFeature({
|
||||
name: "blog",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
getArticles: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
getArticleBySlug: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
createArticle: {
|
||||
mutates: true,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
coverage: {
|
||||
bands: {
|
||||
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
|
||||
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
"use-cases": {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
controllers: {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
mutationTargets: ["entities", "use-cases"],
|
||||
},
|
||||
} as const);
|
||||
|
||||
export type BlogManifest = typeof blogManifest;
|
||||
36
packages/blog/src/index.ts
Normal file
36
packages/blog/src/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type { Article, ArticleStatus } from "./entities/models/article";
|
||||
export type { BlogRouter } from "./integrations/api/router";
|
||||
export { ArticleNotFoundError } from "./entities/errors/article";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
|
||||
// Use case schemas + types
|
||||
export {
|
||||
getArticlesInputSchema,
|
||||
getArticlesOutputSchema,
|
||||
type GetArticlesInput,
|
||||
type GetArticlesOutput,
|
||||
type IGetArticlesUseCase,
|
||||
} from "./application/use-cases/get-articles.use-case";
|
||||
export {
|
||||
createArticleInputSchema,
|
||||
createArticleOutputSchema,
|
||||
type CreateArticleInput,
|
||||
type CreateArticleOutput,
|
||||
type ICreateArticleUseCase,
|
||||
} from "./application/use-cases/create-article.use-case";
|
||||
export {
|
||||
getArticleBySlugInputSchema,
|
||||
getArticleBySlugOutputSchema,
|
||||
type GetArticleBySlugInput,
|
||||
type GetArticleBySlugOutput,
|
||||
type IGetArticleBySlugUseCase,
|
||||
} from "./application/use-cases/get-article-by-slug.use-case";
|
||||
|
||||
// Controller type aliases
|
||||
export type { IGetArticlesController } from "./interface-adapters/controllers/get-articles.controller";
|
||||
export type { ICreateArticleController } from "./interface-adapters/controllers/create-article.controller";
|
||||
export type { IGetArticleBySlugController } from "./interface-adapters/controllers/get-article-by-slug.controller";
|
||||
|
||||
// <gen:events>
|
||||
// <gen:realtime-channels>
|
||||
export { blogManifest, type BlogManifest } from "./feature.manifest";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe } from "vitest";
|
||||
import { RecordingTracer } from "@repo/core-testing/instrumentation";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { articlesRepositoryContract } from "@/__contracts__/articles-repository.contract";
|
||||
|
||||
describe("MockArticlesRepository", () => {
|
||||
const tracer = new RecordingTracer();
|
||||
articlesRepositoryContract.run(
|
||||
() => new MockArticlesRepository(tracer),
|
||||
{ tracer: () => tracer },
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user