refactor(blog)!: delete blog demo feature
Veect retrofit (ADR-027): the template's demo content is being removed to make room for the Veect package map. This slice deletes packages/blog whole and prunes every composition edge in one commit: core-api router mount, core-cms collections + regenerated Payload types, web-next bindAll entry / home page / blog route / Tailwind sources, blog e2e spec, tsconfig paths, fallow ignore entry, anchor-guard + generator e2e feature lists, compliance artifacts, and feature-list doc entries (CLAUDE.md, AGENTS.md, glossary). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -28,7 +28,6 @@
|
||||
"@tanstack/react-query",
|
||||
"@trpc/server",
|
||||
"superjson",
|
||||
"@repo/blog",
|
||||
"@repo/core-api",
|
||||
"@repo/marketing-pages",
|
||||
"@repo/navigation",
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -37,7 +37,6 @@ See `docs/guides/runbook.md` for the full workflow.
|
||||
| `@repo/core-cms` | core-composition | Payload config aggregator — imports `@repo/<feature>/cms` only |
|
||||
| `@repo/core-trpc` | core-composition | Frontend tRPC client + framework-specific providers (Next.js, TanStack) |
|
||||
| `@repo/auth` | feature | Users collection + sign-in/up/out |
|
||||
| `@repo/blog` | feature | Articles collection + article use-cases |
|
||||
| `@repo/media` | feature | Media collection + upload helpers |
|
||||
| `@repo/marketing-pages` | feature | Pages collection + SiteSettings global |
|
||||
| `@repo/navigation` | feature | Header global |
|
||||
@@ -54,7 +53,7 @@ See `docs/guides/runbook.md` for the full workflow.
|
||||
- **app** (3 packages) — `apps/web-next`, `apps/cms`, `apps/storybook`
|
||||
- **core-composition** (3 packages) — `packages/core-api`, `core-cms`, `core-trpc`
|
||||
- **core** (1–2 packages) — `packages/core-shared`; `core-ui` is optional (scaffold with `pnpm turbo gen core-package ui`)
|
||||
- **feature** (5 packages) — `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation`
|
||||
- **feature** (4 packages) — `packages/auth`, `media`, `marketing-pages`, `navigation`
|
||||
- **tooling** (3 packages) — `packages/core-eslint`, `core-typescript`, `core-testing`
|
||||
|
||||
### Allowed dependency directions
|
||||
@@ -128,8 +127,8 @@ docker compose up -d # Start PostgreSQL
|
||||
pnpm dev --filter @repo/web-next # Only Next.js app
|
||||
pnpm dev --filter @repo/cms # Only CMS admin
|
||||
pnpm dev --filter @repo/storybook # Only Storybook
|
||||
pnpm typecheck --filter @repo/blog # Only blog feature
|
||||
pnpm test --filter @repo/blog # Only blog unit/integration tests
|
||||
pnpm typecheck --filter @repo/auth # Only auth feature
|
||||
pnpm test --filter @repo/auth # Only auth unit/integration tests
|
||||
```
|
||||
|
||||
---
|
||||
@@ -385,7 +384,6 @@ export async function bindAllProduction(): Promise<void> {
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
bindProductionBlog(ctx);
|
||||
bindProductionMarketingPages(ctx);
|
||||
bindProductionNavigation(ctx);
|
||||
bindProductionMedia(ctx);
|
||||
@@ -408,12 +406,11 @@ export async function bindAllDevSeed(): Promise<void> {
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
await bindDevSeedBlog(ctx);
|
||||
// ... (same for marketing-pages, navigation, media)
|
||||
}
|
||||
```
|
||||
|
||||
Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.
|
||||
Actual function names: `bindProductionAuth`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.
|
||||
|
||||
Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.
|
||||
|
||||
@@ -579,6 +576,6 @@ Per-package documentation lives in each `AGENTS.md`:
|
||||
- `packages/core-shared/AGENTS.md`
|
||||
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md`
|
||||
- `packages/core-ui/AGENTS.md` (optional — generated by `pnpm turbo gen core-package ui`; see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`)
|
||||
- `packages/auth/AGENTS.md`, `blog/AGENTS.md`, `media/AGENTS.md`, `marketing-pages/AGENTS.md`, `navigation/AGENTS.md`
|
||||
- `packages/auth/AGENTS.md`, `media/AGENTS.md`, `marketing-pages/AGENTS.md`, `navigation/AGENTS.md`
|
||||
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/AGENTS.md`
|
||||
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `storybook/AGENTS.md`
|
||||
|
||||
@@ -44,7 +44,7 @@ See `docs/guides/tdd-workflow.md` for the full cycle.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, `blog`, `media`, `marketing-pages`, `navigation`) owns its Clean Architecture layers. Must-have core packages (`core-shared`, `core-cms`, `core-api`) provide foundation; five optional core packages (`core-realtime`, `core-events`, `core-trpc`, `core-ui`, `core-audit`) scaffold on demand via `pnpm turbo gen core-package <name>` (see `docs/architecture/template-tiers.md`). Two tooling packages (`core-eslint`, `core-typescript`) provide shared configs. Workspace boundaries are enforced by ESLint (lint-time) and Turborepo (build-graph time). Supports Next.js and TanStack Start as frontend frameworks, Payload CMS for content management, and comprehensive agent-optimized documentation.
|
||||
Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, `media`, `marketing-pages`, `navigation`) owns its Clean Architecture layers. Must-have core packages (`core-shared`, `core-cms`, `core-api`) provide foundation; five optional core packages (`core-realtime`, `core-events`, `core-trpc`, `core-ui`, `core-audit`) scaffold on demand via `pnpm turbo gen core-package <name>` (see `docs/architecture/template-tiers.md`). Two tooling packages (`core-eslint`, `core-typescript`) provide shared configs. Workspace boundaries are enforced by ESLint (lint-time) and Turborepo (build-graph time). Supports Next.js and TanStack Start as frontend frameworks, Payload CMS for content management, and comprehensive agent-optimized documentation.
|
||||
|
||||
## Read First
|
||||
|
||||
@@ -95,7 +95,7 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational
|
||||
## Key Conventions
|
||||
|
||||
- **Conventional Commits (non-negotiable)** — Every commit message MUST follow the [Conventional Commits](https://www.conventionalcommits.org/) spec: `<type>(<scope>): <imperative subject>` (≤72 chars). Types: `feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert`. Use `!` after type/scope for breaking changes. Body explains WHY if non-obvious. Examples: `feat(auth): hash password before persisting`, `test(blog): assert article not found error`, `refactor(docs)!: consolidate scaffolding into guides`. The sandcastle implementer + reviewer prompts both enforce this; agents authoring commits autonomously MUST honor it. Commits become versions + changelog entries automatically via release-please (ADR-021 / `docs/guides/releasing.md`).
|
||||
- **Versioning is hybrid (ADR-021)** — Root template (`template-vertical`) + 5 feature packages (`@repo/{auth,blog,media,marketing-pages,navigation}`) each version independently from `0.1.0`. release-please reads Conventional Commits since the last tag and opens a rolling release PR on every merge to main; merging it cuts per-package tags (`template-v0.2.0`, `auth-v0.1.1`, etc.) + GitHub releases. **Bump targeting is by commit path** — files under `packages/<feature>/**` bump that feature; cross-cutting paths (`docs/`, `scripts/`, `.github/`, root configs) bump the root. Pre-1.0 policy: `feat:` → patch, `feat!:` → minor.
|
||||
- **Versioning is hybrid (ADR-021)** — Root template (`template-vertical`) + 4 feature packages (`@repo/{auth,media,marketing-pages,navigation}`) each version independently from `0.1.0`. release-please reads Conventional Commits since the last tag and opens a rolling release PR on every merge to main; merging it cuts per-package tags (`template-v0.2.0`, `auth-v0.1.1`, etc.) + GitHub releases. **Bump targeting is by commit path** — files under `packages/<feature>/**` bump that feature; cross-cutting paths (`docs/`, `scripts/`, `.github/`, root configs) bump the root. Pre-1.0 policy: `feat:` → patch, `feat!:` → minor.
|
||||
- **Relative imports in `src/`** — Source files use relative paths (`../repositories/...`), not `@/` alias
|
||||
- **`@/` alias in tests** — Test files (`*.test.ts`) use `@/` to import from `src/`
|
||||
- **`vitest.config.ts`** — Every package must define `resolve.alias: { "@": path.resolve(__dirname, "./src") }`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
This app exists solely to serve the Payload Admin UI. It contains no custom CMS code beyond Next.js routing boilerplate. All business knowledge lives in feature packages (`@repo/auth`, `@repo/blog`, etc.), which export their collections/globals via subpath exports (`.../cms`). `@repo/core-cms` composes them into a single Payload config.
|
||||
This app exists solely to serve the Payload Admin UI. It contains no custom CMS code beyond Next.js routing boilerplate. All business knowledge lives in feature packages (`@repo/auth`, etc.), which export their collections/globals via subpath exports (`.../cms`). `@repo/core-cms` composes them into a single Payload config.
|
||||
|
||||
## Port: 3001
|
||||
|
||||
@@ -16,7 +16,7 @@ pnpm dev --filter @repo/cms # http://localhost:3001/admin
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `next.config.mjs` | Minimal config wrapped with `withPayload()` from `@payloadcms/next` |
|
||||
| `tsconfig.json` | TypeScript config with `@payload-config` path alias |
|
||||
| `src/app/(payload)/layout.tsx` | Auto-generated Payload root layout (DO NOT MODIFY) |
|
||||
@@ -49,15 +49,11 @@ When Payload imports `@payload-config`, it resolves to the composed config from
|
||||
## Composition flow
|
||||
|
||||
```
|
||||
Feature 1 (@repo/blog)
|
||||
└─ src/integrations/cms/collections/articles.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Feature 2 (@repo/auth)
|
||||
Feature 1 (@repo/auth)
|
||||
└─ src/integrations/cms/collections/users.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Feature 3 (@repo/navigation)
|
||||
Feature 2 (@repo/navigation)
|
||||
└─ src/integrations/cms/globals/header.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
@@ -84,7 +80,7 @@ cd apps/cms && pnpm generate:types
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| ------------------ | ------------------------------- |
|
||||
| `@repo/core-cms` | Payload config + buildConfig |
|
||||
| `@payloadcms/next` | Next.js integration for Payload |
|
||||
| `payload` | Payload CMS core |
|
||||
|
||||
@@ -5,16 +5,12 @@ describe("CMS app payload.config", () => {
|
||||
it("registers all feature collections", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["users", "articles", "pages", "media"]),
|
||||
);
|
||||
expect(slugs).toEqual(expect.arrayContaining(["users", "pages", "media"]));
|
||||
});
|
||||
|
||||
it("registers all feature globals", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["site-settings", "header"]),
|
||||
);
|
||||
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ Next.js 15 reference application using App Router. Demonstrates consuming featur
|
||||
|
||||
## Purpose
|
||||
|
||||
Thin app showcasing how features work end-to-end. Business logic lives in feature packages (`@repo/auth`, `@repo/blog`, etc.); UI primitives live in `@repo/core-ui`; this app is mostly routes, layouts, and component composition.
|
||||
Thin app showcasing how features work end-to-end. Business logic lives in feature packages (`@repo/auth`, etc.); UI primitives live in `@repo/core-ui`; this app is mostly routes, layouts, and component composition.
|
||||
|
||||
## Port: 3000
|
||||
|
||||
@@ -23,11 +23,10 @@ pnpm dev --filter @repo/web-next # Next.js on port 3000
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `src/app/layout.tsx` | Root layout — wraps app with `<Providers>` |
|
||||
| `src/app/providers.tsx` | Client component wrapper (add tRPC/React Query here after scaffolding `@repo/core-trpc`) |
|
||||
| `src/app/page.tsx` | Home page — navigation + marketing content |
|
||||
| `src/app/blog/[slug]/page.tsx` | Dynamic blog post route |
|
||||
| `e2e/` | Playwright end-to-end tests |
|
||||
|
||||
## tRPC Setup (optional)
|
||||
@@ -68,17 +67,17 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| --------------------------------- | ---------------------------------------------------------- |
|
||||
| `@repo/core-api` | `appRouter` for tRPC endpoint |
|
||||
| `@repo/core-trpc/next` | Next.js tRPC client + provider (optional — scaffold first) |
|
||||
| `@repo/core-ui` | Design system components (optional — scaffold first) |
|
||||
| `@repo/auth`, `@repo/blog`, etc. | Feature packages (indirectly via core-api) |
|
||||
| `@repo/auth`, `@repo/media`, etc. | Feature packages (indirectly via core-api) |
|
||||
| `next` | Next.js 15 framework |
|
||||
| `@trpc/server` | tRPC server (fetch adapter) |
|
||||
|
||||
## Test conventions
|
||||
|
||||
- Unit tests colocated: `src/app/blog/article-list.test.tsx`
|
||||
- Unit tests colocated: `src/app/providers.test.tsx`
|
||||
- Vitest environment: `jsdom`
|
||||
- e2e tests in `e2e/` folder: `*.spec.ts`
|
||||
- Run: `pnpm test --filter @repo/web-next` (units) or `pnpm test:e2e` (Playwright)
|
||||
@@ -105,7 +104,7 @@ Run: `pnpm test:e2e` starts the dev server and runs all `.spec.ts` files.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Feature packages:** `packages/{auth,blog,media,marketing-pages,navigation}/`
|
||||
- **Feature packages:** `packages/{auth,media,marketing-pages,navigation}/`
|
||||
- **tRPC composition:** `packages/core-api/AGENTS.md`
|
||||
- **tRPC client + provider (optional):** scaffold `@repo/core-trpc` first, then see `turbo/generators/templates/core-package/trpc/AGENTS.md.hbs`
|
||||
- **UI components (optional):** scaffold with `pnpm turbo gen core-package ui`, then see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("/blog/[slug] returns 404 for non-existent slug", async ({ page }) => {
|
||||
const response = await page.goto("/blog/this-slug-does-not-exist", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("/blog/[slug] for a real slug renders the article", async ({ page }) => {
|
||||
// The mock blog repository is empty by default — so this test currently
|
||||
// expects 404. When seeded data exists in Payload, replace 404 with 200
|
||||
// and check for article.title in the page body.
|
||||
test.skip(
|
||||
true,
|
||||
"Pending: seed a published article in Payload before enabling this test",
|
||||
);
|
||||
await page.goto("/blog/example-slug");
|
||||
await expect(page.locator("h1").first()).toBeVisible();
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { withSentryConfig } from "@sentry/nextjs";
|
||||
const nextConfig = {
|
||||
transpilePackages: [
|
||||
"@repo/auth",
|
||||
"@repo/blog",
|
||||
"@repo/core-analytics",
|
||||
"@repo/core-api",
|
||||
"@repo/core-audit",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/blog": "workspace:*",
|
||||
"@repo/core-api": "workspace:*",
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ArticleDetail } from "@repo/blog/ui";
|
||||
import { bindAll } from "../../../server/bind-production";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
await bindAll();
|
||||
const { slug } = await params;
|
||||
|
||||
return (
|
||||
<main className="px-6 py-8">
|
||||
<ArticleDetail slug={slug} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ArticleList } from "@repo/blog/ui";
|
||||
import { bindAll } from "../server/bind-production";
|
||||
|
||||
export default async function Home() {
|
||||
@@ -6,10 +5,7 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h2 className="mb-4 text-2xl font-bold text-foreground">
|
||||
Latest articles
|
||||
</h2>
|
||||
<ArticleList />
|
||||
<h1 className="mb-4 text-2xl font-bold text-foreground">Veect</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
||||
}));
|
||||
vi.mock("@repo/blog/di/bind-production", () => ({
|
||||
bindProductionBlog: vi.fn(),
|
||||
}));
|
||||
vi.mock("@repo/auth/di/bind-production", () => ({
|
||||
bindProductionAuth: vi.fn(),
|
||||
}));
|
||||
@@ -19,7 +16,6 @@ vi.mock("@repo/navigation/di/bind-production", () => ({
|
||||
vi.mock("@repo/media/di/bind-production", () => ({
|
||||
bindProductionMedia: vi.fn(),
|
||||
}));
|
||||
vi.mock("@repo/blog/di/bind-dev-seed", () => ({ bindDevSeedBlog: vi.fn() }));
|
||||
vi.mock("@repo/auth/di/bind-dev-seed", () => ({ bindDevSeedAuth: vi.fn() }));
|
||||
vi.mock("@repo/marketing-pages/di/bind-dev-seed", () => ({
|
||||
bindDevSeedMarketingPages: vi.fn(),
|
||||
@@ -47,10 +43,8 @@ describe("bindAllProduction", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("binds all five feature production repos", async () => {
|
||||
it("binds all four feature production repos", async () => {
|
||||
const { bindAllProduction } = await import("./bind-production");
|
||||
const { bindProductionBlog } =
|
||||
await import("@repo/blog/di/bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
const { bindProductionMarketingPages } =
|
||||
@@ -62,7 +56,6 @@ describe("bindAllProduction", () => {
|
||||
|
||||
await bindAllProduction();
|
||||
|
||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
expect(bindProductionMarketingPages).toHaveBeenCalledOnce();
|
||||
expect(bindProductionNavigation).toHaveBeenCalledOnce();
|
||||
@@ -72,11 +65,11 @@ describe("bindAllProduction", () => {
|
||||
it("is idempotent via bindAll — second call does not re-bind", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindProductionBlog } =
|
||||
await import("@repo/blog/di/bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
await bindAll();
|
||||
await bindAll();
|
||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("passes a Payload-backed queue to each per-feature binder", async () => {
|
||||
@@ -127,54 +120,54 @@ describe("bindAll dispatcher", () => {
|
||||
vi.stubEnv("USE_DEV_SEED", "true");
|
||||
vi.stubEnv("NODE_ENV", "production"); // even in production, dev seed wins
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||
const { bindProductionBlog } =
|
||||
await import("@repo/blog/di/bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
||||
expect(bindProductionBlog).not.toHaveBeenCalled();
|
||||
expect(bindDevSeedAuth).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("NODE_ENV='production' (no override) → dispatches to bindAllProduction", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindProductionBlog } =
|
||||
await import("@repo/blog/di/bind-production");
|
||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedBlog).not.toHaveBeenCalled();
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("NODE_ENV='development' → dispatches to bindAllDevSeed (developer default)", async () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||
const { bindProductionBlog } =
|
||||
await import("@repo/blog/di/bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
||||
expect(bindProductionBlog).not.toHaveBeenCalled();
|
||||
expect(bindDevSeedAuth).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("USE_DEV_SEED='false' is treated as not-set (only 'true' triggers dev seed)", async () => {
|
||||
vi.stubEnv("USE_DEV_SEED", "false");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindProductionBlog } =
|
||||
await import("@repo/blog/di/bind-production");
|
||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedBlog).not.toHaveBeenCalled();
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,12 +218,12 @@ describe("bindAll instrumentation orthogonality", () => {
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindOtelInstrumentation } =
|
||||
await import("@repo/core-shared/instrumentation");
|
||||
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindOtelInstrumentation).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Noop instrumentation works alongside production binding (DSN unset, NODE_ENV=production)", async () => {
|
||||
@@ -239,12 +232,12 @@ describe("bindAll instrumentation orthogonality", () => {
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindNoopInstrumentation } =
|
||||
await import("@repo/core-shared/instrumentation");
|
||||
const { bindProductionBlog } =
|
||||
await import("@repo/blog/di/bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
|
||||
expect(bindProductionBlog).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,10 @@ import {
|
||||
type IJobQueue,
|
||||
} from "@repo/core-shared/jobs";
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||||
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
|
||||
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
|
||||
import { bindProductionMedia } from "@repo/media/di/bind-production";
|
||||
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
|
||||
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
||||
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
|
||||
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
|
||||
@@ -100,7 +98,6 @@ export async function bindAllProduction(): Promise<void> {
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
bindProductionBlog(ctx);
|
||||
bindProductionMarketingPages(ctx);
|
||||
bindProductionNavigation(ctx);
|
||||
bindProductionMedia(ctx);
|
||||
@@ -123,7 +120,6 @@ export async function bindAllDevSeed(): Promise<void> {
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
await bindDevSeedBlog(ctx);
|
||||
await bindDevSeedMarketingPages(ctx);
|
||||
await bindDevSeedNavigation(ctx);
|
||||
await bindDevSeedMedia(ctx);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../../packages/core-ui/src";
|
||||
@source "../../../../packages/navigation/src";
|
||||
@source "../../../../packages/blog/src";
|
||||
@source "../../../../packages/marketing-pages/src";
|
||||
@source "../../../../packages/media/src";
|
||||
@source "../../../../packages/auth/src";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,10 +2,6 @@
|
||||
# Generated by scripts/compliance/emit-data-map.mjs — do not edit manually.
|
||||
# Run `pnpm compliance:data-map` to regenerate.
|
||||
collections:
|
||||
articles:
|
||||
auth: false
|
||||
piiFields: []
|
||||
slug: articles
|
||||
media:
|
||||
auth: false
|
||||
piiFields: []
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
# Generated by scripts/compliance/emit-retention-policy.mjs — do not edit manually.
|
||||
# Run `pnpm compliance:retention-policy` to regenerate.
|
||||
collections:
|
||||
articles:
|
||||
postDeletion:
|
||||
action: hard-delete
|
||||
duration: P90D
|
||||
trigger: after-deletion
|
||||
purgeSchedule: monthly
|
||||
slug: articles
|
||||
media:
|
||||
postDeletion:
|
||||
action: hard-delete
|
||||
|
||||
@@ -22,7 +22,7 @@ _Avoid:_ module, library, app (unless specifically `apps/`).
|
||||
The boundary classification on a package (`app`, `core`, `core-composition`, `feature`, `tooling`). Enforced by ESLint (`eslint-plugin-boundaries`) and Turborepo `boundaries`. See `AGENTS.md` → Boundary Rules.
|
||||
|
||||
**Feature** (a.k.a. **feature package**):
|
||||
A vertical slice owning its Clean Architecture layers + integrations under `packages/<name>/`. Currently: `auth`, `blog`, `media`, `marketing-pages`, `navigation`. **In architecture-refactor conversations (the `improve-codebase-architecture` skill), "module" defaults to "feature" — they're the same unit at the highest level of granularity.**
|
||||
A vertical slice owning its Clean Architecture layers + integrations under `packages/<name>/`. Currently: `auth`, `media`, `marketing-pages`, `navigation`. **In architecture-refactor conversations (the `improve-codebase-architecture` skill), "module" defaults to "feature" — they're the same unit at the highest level of granularity.**
|
||||
_Avoid:_ domain, vertical (use "feature" or "vertical feature"). "Module" is acceptable inside the refactor skill specifically, where it abstractly covers "anything with interface + implementation" at any scale (use case, controller, repository port, or full feature).
|
||||
|
||||
**Must-have core**:
|
||||
@@ -190,10 +190,10 @@ The commit-message spec mandated by this template — `<type>(<scope>): <imperat
|
||||
The Google-maintained automation that reads Conventional Commits, derives semver bumps, and opens a rolling release PR with version bumps + per-package CHANGELOG entries on every push to main. Configured in `release-please-config.json` + `.release-please-manifest.json`. ADR-021.
|
||||
|
||||
**Hybrid versioning**:
|
||||
The template's versioning strategy (ADR-021) — root template (`template-vertical`) + 5 feature packages (`@repo/{auth,blog,media,marketing-pages,navigation}`) version independently from `0.1.0`. Core packages, tooling, and apps are NOT versioned (cascade-effect would invalidate the signal).
|
||||
The template's versioning strategy (ADR-021) — root template (`template-vertical`) + 4 feature packages (`@repo/{auth,media,marketing-pages,navigation}`) version independently from `0.1.0`. Core packages, tooling, and apps are NOT versioned (cascade-effect would invalidate the signal).
|
||||
|
||||
**Tag prefix**:
|
||||
The per-package prefix release-please uses to avoid tag collisions in a monorepo — `template-v0.1.0` for the root, `auth-v0.1.0` / `blog-v0.1.0` / etc. for features.
|
||||
The per-package prefix release-please uses to avoid tag collisions in a monorepo — `template-v0.1.0` for the root, `auth-v0.1.0` / etc. for features.
|
||||
|
||||
**Rolling release PR**:
|
||||
The single PR release-please keeps open on `main` and updates idempotently on every push. Contains all pending version bumps + changelog entries since the last release. Merging it cuts the tags + GitHub releases.
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# 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
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,3 +0,0 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export { articleFactory } from "./article.factory";
|
||||
@@ -1,35 +0,0 @@
|
||||
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",
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
import { BlogModule } from "./module";
|
||||
|
||||
export const blogContainer = new Container({ defaultScope: "Singleton" });
|
||||
blogContainer.load(BlogModule);
|
||||
@@ -1,69 +0,0 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -1,6 +0,0 @@
|
||||
export class ArticleNotFoundError extends Error {
|
||||
constructor(message = "Article not found", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ArticleNotFoundError";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
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>;
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
@@ -1,36 +0,0 @@
|
||||
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";
|
||||
@@ -1,12 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { injectable } from "inversify";
|
||||
import {
|
||||
NoopTracer,
|
||||
NoopLogger,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
|
||||
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
@injectable()
|
||||
export class MockArticlesRepository implements IArticlesRepository {
|
||||
private _articles: Article[] = [];
|
||||
private tracer: ITracer;
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
tracer: ITracer = new NoopTracer(),
|
||||
logger: ILogger = new NoopLogger(),
|
||||
) {
|
||||
this.tracer = tracer;
|
||||
this.logger = logger;
|
||||
void this.logger; // currently unused; reserved for future mock-thrown captures
|
||||
}
|
||||
|
||||
async getArticle(id: string): Promise<Article | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.getArticle", op: "repository", attributes: { id } },
|
||||
async (span) => {
|
||||
const found = this._articles.find((a) => a.id === id);
|
||||
span.setAttribute("found", Boolean(found));
|
||||
return found;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.getArticleBySlug", op: "repository", attributes: { slug } },
|
||||
async (span) => {
|
||||
const found = this._articles.find((a) => a.slug === slug);
|
||||
span.setAttribute("found", Boolean(found));
|
||||
return found;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getArticles(options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]> {
|
||||
return this.tracer.startSpan(
|
||||
{
|
||||
name: "articles.getArticles",
|
||||
op: "repository",
|
||||
attributes: {
|
||||
status: options?.status ?? null,
|
||||
authorId: options?.authorId ?? null,
|
||||
limit: options?.limit ?? null,
|
||||
offset: options?.offset ?? null,
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
let result = [...this._articles];
|
||||
if (options?.status) {
|
||||
result = result.filter((a) => a.status === options.status);
|
||||
}
|
||||
if (options?.authorId) {
|
||||
result = result.filter((a) => a.authorId === options.authorId);
|
||||
}
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
const sliced = result.slice(offset, offset + limit);
|
||||
span.setAttribute("count", sliced.length);
|
||||
return sliced;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async createArticle(input: Article): Promise<Article> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } },
|
||||
async (span) => {
|
||||
this._articles.push(input);
|
||||
span.setAttribute("id", input.id);
|
||||
return input;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async updateArticle(
|
||||
id: string,
|
||||
input: Partial<Article>,
|
||||
): Promise<Article | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.updateArticle", op: "repository", attributes: { id } },
|
||||
async (span) => {
|
||||
const idx = this._articles.findIndex((a) => a.id === id);
|
||||
if (idx === -1) {
|
||||
span.setAttribute("found", false);
|
||||
return undefined;
|
||||
}
|
||||
const merged = { ...this._articles[idx]!, ...input, id } as Article;
|
||||
this._articles[idx] = merged;
|
||||
span.setAttribute("found", true);
|
||||
return merged;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
|
||||
// Mock repo also wraps in spans; easier to assert without booting Payload.
|
||||
describe("MockArticlesRepository emits spans", () => {
|
||||
it("getArticles emits one span with op='repository'", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
const repo = new MockArticlesRepository(tracer, logger);
|
||||
await repo.getArticles({ limit: 10 });
|
||||
expect(tracer.spans).toHaveLength(1);
|
||||
expect(tracer.spans[0]).toMatchObject({
|
||||
name: "articles.getArticles",
|
||||
op: "repository",
|
||||
});
|
||||
expect(tracer.spans[0]!.attributes).toMatchObject({ limit: 10 });
|
||||
});
|
||||
|
||||
it("createArticle emits a span with slug attribute", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const repo = new MockArticlesRepository(tracer);
|
||||
await repo.createArticle({
|
||||
id: "a1",
|
||||
title: "T",
|
||||
slug: "t",
|
||||
content: null,
|
||||
status: "draft",
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(tracer.findSpan("articles.createArticle")).toBeDefined();
|
||||
expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe(
|
||||
"t",
|
||||
);
|
||||
});
|
||||
|
||||
it("getArticle records found=false for missing id", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const repo = new MockArticlesRepository(tracer);
|
||||
await repo.getArticle("missing");
|
||||
expect(tracer.spans[0]!.attributes.found).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { RecordingTracer } from "@repo/core-testing/instrumentation";
|
||||
import { ArticlesRepository } from "@/infrastructure/repositories/articles.repository";
|
||||
import { articlesRepositoryContract } from "@/__contracts__/articles-repository.contract";
|
||||
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory Payload stub used by both the contract suite and impl-specific tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildPayloadStub() {
|
||||
const store = new Map<string, Record<string, unknown>>();
|
||||
|
||||
return {
|
||||
create: vi.fn(
|
||||
async ({
|
||||
data,
|
||||
}: {
|
||||
collection: string;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess?: boolean;
|
||||
}) => {
|
||||
// Payload assigns an id; here we require the data to carry one
|
||||
// (the repository passes `id` via the mapped domain object implicitly
|
||||
// through the Article — we expose the mapped doc back from createArticle).
|
||||
// The stub returns the data as-is so the mapDoc function can work.
|
||||
const doc = { id: `stub-${store.size + 1}`, ...data };
|
||||
store.set(String(doc.id), doc);
|
||||
return doc;
|
||||
},
|
||||
),
|
||||
find: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
limit,
|
||||
}: {
|
||||
collection: string;
|
||||
where?: {
|
||||
slug?: { equals: string };
|
||||
status?: { equals: string };
|
||||
author?: { equals: string };
|
||||
};
|
||||
limit?: number;
|
||||
page?: number;
|
||||
overrideAccess?: boolean;
|
||||
}) => {
|
||||
let docs = Array.from(store.values());
|
||||
if (where?.slug) {
|
||||
docs = docs.filter((d) => d.slug === where.slug?.equals);
|
||||
}
|
||||
if (where?.status) {
|
||||
docs = docs.filter((d) => d.status === where.status?.equals);
|
||||
}
|
||||
if (where?.author) {
|
||||
docs = docs.filter((d) => d.author === where.author?.equals);
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
docs = docs.slice(0, limit);
|
||||
}
|
||||
return { docs };
|
||||
},
|
||||
),
|
||||
findByID: vi.fn(
|
||||
async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => {
|
||||
const doc = store.get(String(id));
|
||||
if (!doc) {
|
||||
const err = Object.assign(new Error(`Not found: ${id}`), { status: 404 });
|
||||
throw err;
|
||||
}
|
||||
return doc;
|
||||
},
|
||||
),
|
||||
update: vi.fn(
|
||||
async ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
collection: string;
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess?: boolean;
|
||||
}) => {
|
||||
const existing = store.get(String(id));
|
||||
if (!existing) {
|
||||
const err = Object.assign(new Error(`Not found: ${id}`), { status: 404 });
|
||||
throw err;
|
||||
}
|
||||
const updated = { ...existing, ...data };
|
||||
store.set(String(id), updated);
|
||||
return updated;
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ArticlesRepository", () => {
|
||||
describe("contract", () => {
|
||||
const tracer = new RecordingTracer();
|
||||
articlesRepositoryContract.run(
|
||||
async () => {
|
||||
const stub = buildPayloadStub();
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
return new ArticlesRepository(stubPayloadConfig, tracer);
|
||||
},
|
||||
{ tracer: () => tracer },
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Impl-specific tests: Payload doc → domain mapping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Payload doc → domain mapping", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps a Payload doc to a domain Article on getArticleBySlug", async () => {
|
||||
const { getPayload } = await import("payload");
|
||||
const findMock = vi.fn().mockResolvedValue({
|
||||
docs: [
|
||||
{
|
||||
id: "p-123",
|
||||
title: "Hello",
|
||||
slug: "hello",
|
||||
content: { type: "doc", children: [] },
|
||||
status: "published",
|
||||
author: "u1",
|
||||
createdAt: "2026-05-04T12:00:00.000Z",
|
||||
updatedAt: "2026-05-04T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
find: findMock,
|
||||
});
|
||||
|
||||
const repo = new ArticlesRepository(stubPayloadConfig);
|
||||
const result = await repo.getArticleBySlug("hello");
|
||||
|
||||
expect(findMock).toHaveBeenCalledWith({
|
||||
collection: "articles",
|
||||
where: { slug: { equals: "hello" } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(result?.id).toBe("p-123");
|
||||
expect(result?.slug).toBe("hello");
|
||||
expect(result?.status).toBe("published");
|
||||
expect(result?.authorId).toBe("u1");
|
||||
expect(result?.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns undefined when slug is not found", async () => {
|
||||
const { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
find: vi.fn().mockResolvedValue({ docs: [] }),
|
||||
});
|
||||
|
||||
const repo = new ArticlesRepository(stubPayloadConfig);
|
||||
const result = await repo.getArticleBySlug("missing");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { injectable } from "inversify";
|
||||
import { getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import {
|
||||
NoopTracer,
|
||||
NoopLogger,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
|
||||
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
type PayloadArticleDoc = {
|
||||
id: string | number;
|
||||
title?: string | null;
|
||||
slug?: string | null;
|
||||
content?: unknown;
|
||||
status?: string | null;
|
||||
author?: string | number | { id: string | number } | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
function mapDoc(doc: PayloadArticleDoc): Article {
|
||||
const authorId =
|
||||
typeof doc.author === "object" && doc.author !== null
|
||||
? String(doc.author.id)
|
||||
: doc.author != null
|
||||
? String(doc.author)
|
||||
: "";
|
||||
return {
|
||||
id: String(doc.id),
|
||||
title: doc.title ?? "",
|
||||
slug: doc.slug ?? "",
|
||||
content: doc.content ?? null,
|
||||
status: doc.status === "published" ? "published" : "draft",
|
||||
authorId,
|
||||
createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
|
||||
updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
const FEATURE = "blog" as const;
|
||||
const REPO = "articles" as const;
|
||||
|
||||
@injectable()
|
||||
export class ArticlesRepository implements IArticlesRepository {
|
||||
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 getArticle(id: string): Promise<Article | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.getArticle", op: "repository", attributes: { id } },
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const doc = await payload.findByID({
|
||||
collection: "articles",
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
span.setAttribute("found", true);
|
||||
return mapDoc(doc as PayloadArticleDoc);
|
||||
} catch (err) {
|
||||
// Payload throws on not-found; treat as undefined per existing semantics
|
||||
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: "getArticle" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.getArticleBySlug", op: "repository", attributes: { slug } },
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const result = await payload.find({
|
||||
collection: "articles",
|
||||
where: { slug: { equals: slug } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const doc = result.docs[0] as PayloadArticleDoc | undefined;
|
||||
span.setAttribute("found", Boolean(doc));
|
||||
return doc ? mapDoc(doc) : undefined;
|
||||
} catch (err) {
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getArticles(options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]> {
|
||||
return this.tracer.startSpan(
|
||||
{
|
||||
name: "articles.getArticles",
|
||||
op: "repository",
|
||||
attributes: {
|
||||
status: options?.status ?? null,
|
||||
authorId: options?.authorId ?? null,
|
||||
limit: options?.limit ?? null,
|
||||
offset: options?.offset ?? null,
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const where: Record<string, { equals: string }> = {};
|
||||
if (options?.status) where.status = { equals: options.status };
|
||||
if (options?.authorId) where.author = { equals: options.authorId };
|
||||
|
||||
const result = await payload.find({
|
||||
collection: "articles",
|
||||
where: where as never,
|
||||
limit: options?.limit ?? 50,
|
||||
page: options?.offset
|
||||
? Math.floor(options.offset / (options.limit ?? 50)) + 1
|
||||
: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
span.setAttribute("count", result.docs.length);
|
||||
return result.docs.map((d) => mapDoc(d as PayloadArticleDoc));
|
||||
} catch (err) {
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: FEATURE, repo: REPO, method: "getArticles" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async createArticle(input: Article): Promise<Article> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } },
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const created = await payload.create({
|
||||
collection: "articles",
|
||||
data: {
|
||||
title: input.title,
|
||||
slug: input.slug,
|
||||
content: input.content,
|
||||
status: input.status,
|
||||
author: input.authorId,
|
||||
} as never,
|
||||
overrideAccess: true,
|
||||
});
|
||||
span.setAttribute("id", String((created as PayloadArticleDoc).id));
|
||||
return mapDoc(created as PayloadArticleDoc);
|
||||
} catch (err) {
|
||||
this.logger.captureException(err, {
|
||||
tags: { feature: FEATURE, repo: REPO, method: "createArticle" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async updateArticle(
|
||||
id: string,
|
||||
input: Partial<Article>,
|
||||
): Promise<Article | undefined> {
|
||||
return this.tracer.startSpan(
|
||||
{ name: "articles.updateArticle", op: "repository", attributes: { id } },
|
||||
async (span) => {
|
||||
try {
|
||||
const payload = await getPayload({ config: this.config });
|
||||
const updated = await payload.update({
|
||||
collection: "articles",
|
||||
id,
|
||||
data: {
|
||||
...(input.title !== undefined && { title: input.title }),
|
||||
...(input.slug !== undefined && { slug: input.slug }),
|
||||
...(input.content !== undefined && { content: input.content }),
|
||||
...(input.status !== undefined && { status: input.status }),
|
||||
...(input.authorId !== undefined && { author: input.authorId }),
|
||||
} as never,
|
||||
overrideAccess: true,
|
||||
});
|
||||
span.setAttribute("found", true);
|
||||
return mapDoc(updated as PayloadArticleDoc);
|
||||
} 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: "updateArticle" },
|
||||
});
|
||||
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
|
||||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
|
||||
export const blogProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([
|
||||
[InputParseError, "BAD_REQUEST"],
|
||||
[ArticleNotFoundError, "NOT_FOUND"],
|
||||
]),
|
||||
);
|
||||
@@ -1,79 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { blogContainer } from "@/di/container";
|
||||
import { BlogModule } from "@/di/module";
|
||||
import { blogRouter } from "@/integrations/api/router";
|
||||
|
||||
// The router resolves controllers from blogContainer (a singleton).
|
||||
// We reload the module between tests to get a fresh MockArticlesRepository.
|
||||
describe("blogRouter", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("exposes articleBySlug, listArticles, createArticle procedures", () => {
|
||||
const procedureNames = Object.keys(blogRouter._def.procedures);
|
||||
expect(procedureNames).toContain("articleBySlug");
|
||||
expect(procedureNames).toContain("listArticles");
|
||||
expect(procedureNames).toContain("createArticle");
|
||||
});
|
||||
|
||||
it("listArticles returns empty array by default", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
const result = await caller.listArticles({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("createArticle then articleBySlug returns the article", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
|
||||
const created = await caller.createArticle({
|
||||
title: "Router Test Article",
|
||||
content: null,
|
||||
authorId: "u1",
|
||||
slug: "router-test",
|
||||
});
|
||||
expect(created.slug).toBe("router-test");
|
||||
|
||||
const fetched = await caller.articleBySlug({ slug: "router-test" });
|
||||
expect(fetched.id).toBe(created.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blogRouter error mapping", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
try {
|
||||
await caller.articleBySlug({ slug: "missing" });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("NOT_FOUND");
|
||||
}
|
||||
});
|
||||
|
||||
it("translates zod parse failure → BAD_REQUEST", async () => {
|
||||
const caller = blogRouter.createCaller({});
|
||||
try {
|
||||
await caller.articleBySlug({} as unknown as { slug: string });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(TRPCError);
|
||||
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { router } from "@repo/core-shared/trpc/init";
|
||||
|
||||
import { blogContainer } from "../../di/container";
|
||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||
|
||||
import { getArticlesInputSchema } from "../../application/use-cases/get-articles.use-case";
|
||||
import { createArticleInputSchema } from "../../application/use-cases/create-article.use-case";
|
||||
import { getArticleBySlugInputSchema } from "../../application/use-cases/get-article-by-slug.use-case";
|
||||
|
||||
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
|
||||
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
||||
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
|
||||
import { blogProcedure } from "./procedures";
|
||||
|
||||
export const blogRouter = router({
|
||||
articleBySlug: blogProcedure
|
||||
.input(getArticleBySlugInputSchema)
|
||||
.query(({ input }) => {
|
||||
const ctrl = blogContainer.get<IGetArticleBySlugController>(
|
||||
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||||
);
|
||||
return ctrl(input);
|
||||
}),
|
||||
|
||||
listArticles: blogProcedure
|
||||
.input(getArticlesInputSchema)
|
||||
.query(({ input }) => {
|
||||
const ctrl = blogContainer.get<IGetArticlesController>(
|
||||
BLOG_SYMBOLS.IGetArticlesController,
|
||||
);
|
||||
return ctrl(input);
|
||||
}),
|
||||
|
||||
createArticle: blogProcedure
|
||||
.input(createArticleInputSchema)
|
||||
.mutation(({ input }) => {
|
||||
const ctrl = blogContainer.get<ICreateArticleController>(
|
||||
BLOG_SYMBOLS.ICreateArticleController,
|
||||
);
|
||||
return ctrl(input);
|
||||
}),
|
||||
});
|
||||
|
||||
export type BlogRouter = typeof blogRouter;
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { slugifyIfMissing } from "@repo/core-shared/payload";
|
||||
|
||||
export const articles: CollectionConfig = {
|
||||
slug: "articles",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "monthly",
|
||||
postDeletion: {
|
||||
duration: "P90D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "author", "updatedAt"],
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [slugifyIfMissing],
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
unique: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Auto-generated from title if left empty",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
defaultValue: "draft",
|
||||
required: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "featuredImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
},
|
||||
{
|
||||
name: "publishedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
date: {
|
||||
pickerAppearance: "dayAndTime",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { articles } from "./collections/articles";
|
||||
// <gen:job-tasks>
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createArticleController } from "@/interface-adapters/controllers/create-article.controller";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { createArticleUseCase } from "@/application/use-cases/create-article.use-case";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
describe("createArticleController", () => {
|
||||
it("creates an article on valid input", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = createArticleUseCase(repo);
|
||||
const controller = createArticleController(useCase);
|
||||
|
||||
const result = await controller({ title: "Hello", content: "body", authorId: "u1" });
|
||||
expect(result.title).toBe("Hello");
|
||||
expect(result.slug).toBe("hello");
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("throws InputParseError on missing title", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = createArticleUseCase(repo);
|
||||
const controller = createArticleController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({ content: "body", authorId: "u1" }),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
|
||||
it("throws InputParseError on missing authorId", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = createArticleUseCase(repo);
|
||||
const controller = createArticleController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({ title: "T" }),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
createArticleInputSchema,
|
||||
type CreateArticleOutput,
|
||||
type ICreateArticleUseCase,
|
||||
} from "../../application/use-cases/create-article.use-case";
|
||||
|
||||
function presenter(value: CreateArticleOutput) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export type ICreateArticleController = ReturnType<typeof createArticleController>;
|
||||
|
||||
export const createArticleController =
|
||||
(createArticleUseCase: ICreateArticleUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = createArticleInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid create-article input", { cause: parsed.error });
|
||||
}
|
||||
const result = await createArticleUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getArticleBySlugController } from "@/interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import { ArticleNotFoundError } from "@/entities/errors/article";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
describe("getArticleBySlugController", () => {
|
||||
it("returns 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 controller = getArticleBySlugController(useCase);
|
||||
|
||||
const result = await controller({ slug: "test-slug" });
|
||||
expect(result.slug).toBe("test-slug");
|
||||
});
|
||||
|
||||
it("throws ArticleNotFoundError for missing slug", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const controller = getArticleBySlugController(useCase);
|
||||
|
||||
await expect(controller({ slug: "nope" })).rejects.toBeInstanceOf(ArticleNotFoundError);
|
||||
});
|
||||
|
||||
it("throws InputParseError on empty slug", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const controller = getArticleBySlugController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({}),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
getArticleBySlugInputSchema,
|
||||
type GetArticleBySlugOutput,
|
||||
type IGetArticleBySlugUseCase,
|
||||
} from "../../application/use-cases/get-article-by-slug.use-case";
|
||||
|
||||
function presenter(value: GetArticleBySlugOutput) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
|
||||
|
||||
export const getArticleBySlugController =
|
||||
(getArticleBySlugUseCase: IGetArticleBySlugUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = getArticleBySlugInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
|
||||
}
|
||||
const result = await getArticleBySlugUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getArticlesController } from "@/interface-adapters/controllers/get-articles.controller";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
describe("getArticlesController", () => {
|
||||
it("returns array on valid input", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const controller = getArticlesController(useCase);
|
||||
|
||||
const result = await controller({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters by status", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(articleFactory.build({ id: "1", slug: "a", status: "draft" }));
|
||||
await repo.createArticle(articleFactory.build({ id: "2", slug: "b", status: "published" }));
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const controller = getArticlesController(useCase);
|
||||
|
||||
const result = await controller({ status: "published" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("2");
|
||||
});
|
||||
|
||||
it("throws InputParseError on invalid input shape", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const controller = getArticlesController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({ limit: "not a number" }),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import {
|
||||
getArticlesInputSchema,
|
||||
type GetArticlesOutput,
|
||||
type IGetArticlesUseCase,
|
||||
} from "../../application/use-cases/get-articles.use-case";
|
||||
|
||||
function presenter(value: GetArticlesOutput) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
||||
|
||||
export const getArticlesController =
|
||||
(getArticlesUseCase: IGetArticlesUseCase) =>
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = getArticlesInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid get-articles input", { cause: parsed.error });
|
||||
}
|
||||
const result = await getArticlesUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export type ArticleCardProps = {
|
||||
article: Article;
|
||||
};
|
||||
|
||||
export function ArticleCard({ article }: ArticleCardProps) {
|
||||
return (
|
||||
<article className="rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50">
|
||||
<a href={`/blog/${article.slug}`}>
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
{article.title}
|
||||
</h3>
|
||||
</a>
|
||||
<time
|
||||
className="text-sm text-muted-foreground"
|
||||
dateTime={article.createdAt.toISOString()}
|
||||
>
|
||||
{article.createdAt.toLocaleDateString()}
|
||||
</time>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useArticleBySlug } from "../hooks/use-article-by-slug";
|
||||
|
||||
export type ArticleDetailProps = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export function ArticleDetail({ slug }: ArticleDetailProps) {
|
||||
const { data: article } = useArticleBySlug(slug);
|
||||
|
||||
if (!article) return null;
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">{article.title}</h1>
|
||||
{article.createdAt ? (
|
||||
<time
|
||||
className="mt-2 block text-sm text-muted-foreground"
|
||||
dateTime={article.createdAt.toISOString()}
|
||||
>
|
||||
{article.createdAt.toLocaleDateString()}
|
||||
</time>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="prose text-foreground">
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{JSON.stringify(article.content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getQueryClient } from "@repo/core-trpc";
|
||||
import { blogContainer } from "../../di/container";
|
||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { ArticleDetail as ArticleDetailClient } from "./article-detail.client";
|
||||
|
||||
export async function ArticleDetail({ slug }: { slug: string }) {
|
||||
const controller = blogContainer.get<IGetArticleBySlugController>(
|
||||
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||||
);
|
||||
const article = await controller({ slug });
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.setQueryData(
|
||||
["blog", "articleBySlug", { input: { slug } }],
|
||||
article,
|
||||
);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ArticleDetailClient slug={slug} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useArticleList } from "../hooks/use-article-list";
|
||||
import { ArticleCard } from "./article-card";
|
||||
|
||||
export function ArticleList() {
|
||||
const { data: articles } = useArticleList();
|
||||
|
||||
if (articles.length === 0) {
|
||||
return <p className="text-muted-foreground">No published articles yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard key={article.id} article={article} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getQueryClient } from "@repo/core-trpc";
|
||||
import { blogContainer } from "../../di/container";
|
||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
|
||||
import { ArticleList as ArticleListClient } from "./article-list.client";
|
||||
|
||||
export async function ArticleList() {
|
||||
const controller = blogContainer.get<IGetArticlesController>(
|
||||
BLOG_SYMBOLS.IGetArticlesController,
|
||||
);
|
||||
const articles = await controller({ status: "published", limit: 20 });
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.setQueryData(
|
||||
["blog", "listArticles", { input: { status: "published", limit: 20 } }],
|
||||
articles,
|
||||
);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ArticleListClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@repo/core-trpc";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export function useArticleBySlug(slug: string) {
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(trpc.blog.articleBySlug.queryOptions({ slug })) as {
|
||||
data: Article | null;
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@repo/core-trpc";
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export function useArticleList(options?: {
|
||||
status?: "draft" | "published";
|
||||
limit?: number;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
return useSuspenseQuery(
|
||||
trpc.blog.listArticles.queryOptions({
|
||||
status: options?.status ?? "published",
|
||||
limit: options?.limit ?? 20,
|
||||
}),
|
||||
) as { data: Article[] };
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { articleBySlugQuery, listArticlesQuery } from "./query";
|
||||
export { useArticleList } from "./hooks/use-article-list";
|
||||
export { useArticleBySlug } from "./hooks/use-article-by-slug";
|
||||
export { ArticleCard, type ArticleCardProps } from "./components/article-card";
|
||||
export { ArticleList } from "./components/article-list.server";
|
||||
export { ArticleDetail } from "./components/article-detail.server";
|
||||
@@ -1,34 +0,0 @@
|
||||
// React Query option builders for blog feature procedures.
|
||||
// Consumed by apps via the @repo/core-trpc client.
|
||||
|
||||
type TrpcClient = {
|
||||
blog: {
|
||||
articleBySlug: {
|
||||
queryOptions: (input: { slug: string }) => unknown;
|
||||
};
|
||||
listArticles: {
|
||||
queryOptions: (input?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function articleBySlugQuery(client: TrpcClient, slug: string) {
|
||||
return client.blog.articleBySlug.queryOptions({ slug });
|
||||
}
|
||||
|
||||
export function listArticlesQuery(
|
||||
client: TrpcClient,
|
||||
options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) {
|
||||
return client.blog.listArticles.queryOptions(options);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Feature-level test: exercises the full slice
|
||||
// use case factory chain → mock repo
|
||||
// Tests the full dependency chain via direct injection (no container rebinding).
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MockArticlesRepository } from "../src/infrastructure/repositories/articles.repository.mock";
|
||||
import { getArticlesUseCase } from "../src/application/use-cases/get-articles.use-case";
|
||||
import { createArticleUseCase } from "../src/application/use-cases/create-article.use-case";
|
||||
import { getArticleBySlugUseCase } from "../src/application/use-cases/get-article-by-slug.use-case";
|
||||
import { getArticlesController } from "../src/interface-adapters/controllers/get-articles.controller";
|
||||
import { createArticleController } from "../src/interface-adapters/controllers/create-article.controller";
|
||||
import { getArticleBySlugController } from "../src/interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { ArticleNotFoundError } from "../src/entities/errors/article";
|
||||
|
||||
describe("blog feature: article end-to-end via direct injection", () => {
|
||||
function buildChain() {
|
||||
const repo = new MockArticlesRepository();
|
||||
const createUseCase = createArticleUseCase(repo);
|
||||
const getArticlesUC = getArticlesUseCase(repo);
|
||||
const getBySlugUC = getArticleBySlugUseCase(repo);
|
||||
const createCtrl = createArticleController(createUseCase);
|
||||
const listCtrl = getArticlesController(getArticlesUC);
|
||||
const bySlugCtrl = getArticleBySlugController(getBySlugUC);
|
||||
return { createCtrl, listCtrl, bySlugCtrl };
|
||||
}
|
||||
|
||||
it("creates an article via controller, then fetches it back by slug", async () => {
|
||||
const { createCtrl, bySlugCtrl } = buildChain();
|
||||
|
||||
const created = await createCtrl({
|
||||
title: "The Vertical Refactor",
|
||||
content: { type: "doc", children: [] },
|
||||
authorId: "u1",
|
||||
slug: "vertical-refactor",
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.slug).toBe("vertical-refactor");
|
||||
|
||||
const fetched = await bySlugCtrl({ slug: "vertical-refactor" });
|
||||
expect(fetched.id).toBe(created.id);
|
||||
expect(fetched.title).toBe("The Vertical Refactor");
|
||||
});
|
||||
|
||||
it("listArticles filters by status", async () => {
|
||||
const { createCtrl, listCtrl } = buildChain();
|
||||
|
||||
await createCtrl({
|
||||
title: "Draft One",
|
||||
content: null,
|
||||
authorId: "u1",
|
||||
});
|
||||
const draftOnly = await listCtrl({ status: "draft" });
|
||||
expect(draftOnly).toHaveLength(1);
|
||||
|
||||
const publishedOnly = await listCtrl({ status: "published" });
|
||||
expect(publishedOnly).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getArticleBySlugController throws ArticleNotFoundError for missing article", async () => {
|
||||
const { bySlugCtrl } = buildChain();
|
||||
await expect(bySlugCtrl({ slug: "missing-slug" })).rejects.toBeInstanceOf(ArticleNotFoundError);
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
// Verify the full chain (controller → use case → repo) wraps with
|
||||
// withSpan + withCapture and never double-captures the same error.
|
||||
//
|
||||
// Each layer's withCapture catch checks the __sentryReported flag (set by
|
||||
// the inner-most layer that captured first) and skips. End result: one
|
||||
// error → one logger.captureException call, with the inner-most tags.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
RecordingTracer,
|
||||
RecordingLogger,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
|
||||
|
||||
import { MockArticlesRepository } from "../src/infrastructure/repositories/articles.repository.mock";
|
||||
import { getArticleBySlugUseCase } from "../src/application/use-cases/get-article-by-slug.use-case";
|
||||
import { getArticleBySlugController } from "../src/interface-adapters/controllers/get-article-by-slug.controller";
|
||||
|
||||
describe("no double-capture across span/capture-wrapped layers", () => {
|
||||
it("an error originated in the repo is captured exactly once with repo tags", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
// Repo throws on getArticleBySlug for a special slug. We simulate the
|
||||
// throw via a subclass — the production repos call captureException
|
||||
// inside their own try/catch as part of the repo span body.
|
||||
class ThrowingRepo extends MockArticlesRepository {
|
||||
override getArticleBySlug = async (_slug: string) => {
|
||||
const err = new Error("boom from repo");
|
||||
// Mirror what the real repo does: capture with repo tags, mark the
|
||||
// flag (RecordingLogger.captureException does this for us now).
|
||||
logger.captureException(err, {
|
||||
tags: {
|
||||
feature: "blog",
|
||||
repo: "articles",
|
||||
method: "getArticleBySlug",
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
};
|
||||
}
|
||||
|
||||
const repo = new ThrowingRepo(tracer, logger);
|
||||
|
||||
// Wire the use case + controller exactly the way bind-production does.
|
||||
const wrappedUC = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "use-case", name: "blog.getArticleBySlug" },
|
||||
getArticleBySlugUseCase(repo),
|
||||
),
|
||||
);
|
||||
const wrappedCtrl = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
|
||||
getArticleBySlugController(wrappedUC),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(wrappedCtrl({ slug: "anything" })).rejects.toThrow(
|
||||
"boom from repo",
|
||||
);
|
||||
|
||||
// Exactly one capture. Outer wrappers saw the flag and skipped.
|
||||
expect(logger.captures).toHaveLength(1);
|
||||
const only = logger.captures[0];
|
||||
expect(only?.kind).toBe("exception");
|
||||
if (only?.kind !== "exception") return;
|
||||
expect(only.ctx?.tags).toEqual({
|
||||
feature: "blog",
|
||||
repo: "articles",
|
||||
method: "getArticleBySlug",
|
||||
});
|
||||
});
|
||||
|
||||
it("an error originated in the controller (parse failure) is captured once with controller tags", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
const repo = new MockArticlesRepository();
|
||||
|
||||
const wrappedUC = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "use-case", name: "blog.getArticleBySlug" },
|
||||
getArticleBySlugUseCase(repo),
|
||||
),
|
||||
);
|
||||
const wrappedCtrl = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticleBySlug" },
|
||||
getArticleBySlugController(wrappedUC),
|
||||
),
|
||||
);
|
||||
|
||||
// safeParse fails because slug is missing.
|
||||
await expect(wrappedCtrl({})).rejects.toThrow();
|
||||
|
||||
expect(logger.captures).toHaveLength(1);
|
||||
const only = logger.captures[0];
|
||||
expect(only?.kind).toBe("exception");
|
||||
if (only?.kind !== "exception") return;
|
||||
expect(only.ctx?.tags).toEqual({
|
||||
feature: "blog",
|
||||
layer: "controller",
|
||||
name: "blog.getArticleBySlug",
|
||||
});
|
||||
});
|
||||
|
||||
it("a successful call captures nothing", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
const repo = new MockArticlesRepository();
|
||||
|
||||
const wrappedUC = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "use-case" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "use-case", name: "blog.getArticles" },
|
||||
async () => [],
|
||||
),
|
||||
);
|
||||
const wrappedCtrl = withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
|
||||
async () => wrappedUC(),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(wrappedCtrl()).resolves.toEqual([]);
|
||||
expect(logger.captures).toHaveLength(0);
|
||||
void repo;
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "preserve",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["feature"]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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 (ADR-020). The feature.manifest.ts `coverage.bands` section
|
||||
// declares these for boot-time `assertFeatureConformance`. Edit the
|
||||
// manifest, not this file, when adjusting per-feature bands.
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: [
|
||||
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
|
||||
"src/di/bind-production.ts",
|
||||
// Pure TypeScript interface files — not executable
|
||||
"src/application/repositories/**",
|
||||
// Payload CMS collection config — declarative data, tested via Payload integration
|
||||
"src/integrations/cms/**",
|
||||
// React Query option builders — integration-tested in apps
|
||||
"src/ui/**",
|
||||
],
|
||||
thresholds: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
@@ -14,7 +14,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/blog": "workspace:*",
|
||||
"@repo/core-consent": "workspace:*",
|
||||
"@repo/core-dsr": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { router } from "@repo/core-shared/trpc/init";
|
||||
import { authRouter } from "@repo/auth/api";
|
||||
import { blogRouter } from "@repo/blog/api";
|
||||
import { marketingPagesRouter } from "@repo/marketing-pages/api";
|
||||
import { navigationRouter } from "@repo/navigation/api";
|
||||
import { mediaRouter } from "@repo/media/api";
|
||||
@@ -9,7 +8,6 @@ import { consentRouter } from "@repo/core-consent";
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
blog: blogRouter,
|
||||
marketingPages: marketingPagesRouter,
|
||||
navigation: navigationRouter,
|
||||
media: mediaRouter,
|
||||
|
||||
@@ -65,22 +65,15 @@ function makeUnauthCaller(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("appRouter composition", () => {
|
||||
it("exposes auth, blog, marketingPages, navigation, media routers", () => {
|
||||
it("exposes auth, marketingPages, navigation, media routers", () => {
|
||||
const procedures = appRouter._def.procedures;
|
||||
const keys = Object.keys(procedures);
|
||||
expect(keys.some((k) => k.startsWith("auth."))).toBe(true);
|
||||
expect(keys.some((k) => k.startsWith("blog."))).toBe(true);
|
||||
expect(keys.some((k) => k.startsWith("marketingPages."))).toBe(true);
|
||||
expect(keys.some((k) => k.startsWith("navigation."))).toBe(true);
|
||||
expect(keys.some((k) => k.startsWith("media."))).toBe(true);
|
||||
});
|
||||
|
||||
it("blog router has expected procedures", () => {
|
||||
const procedures = appRouter._def.procedures;
|
||||
expect(procedures).toHaveProperty("blog.articleBySlug");
|
||||
expect(procedures).toHaveProperty("blog.listArticles");
|
||||
});
|
||||
|
||||
it("exposes dsr and consent routers", () => {
|
||||
const keys = Object.keys(appRouter._def.procedures);
|
||||
expect(keys.some((k) => k.startsWith("dsr."))).toBe(true);
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"@payloadcms/db-postgres": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/blog": "workspace:*",
|
||||
"@repo/marketing-pages": "workspace:*",
|
||||
"@repo/media": "workspace:*",
|
||||
"@repo/navigation": "workspace:*",
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface Config {
|
||||
blocks: {};
|
||||
collections: {
|
||||
users: User;
|
||||
articles: Article;
|
||||
pages: Page;
|
||||
media: Media;
|
||||
"payload-kv": PayloadKv;
|
||||
@@ -79,7 +78,6 @@ export interface Config {
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
articles: ArticlesSelect<false> | ArticlesSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
"payload-kv": PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
@@ -169,59 +167,6 @@ export interface User {
|
||||
password?: string | null;
|
||||
collection: "users";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "articles".
|
||||
*/
|
||||
export interface Article {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Auto-generated from title if left empty
|
||||
*/
|
||||
slug?: string | null;
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ("ltr" | "rtl") | null;
|
||||
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
status: "draft" | "published";
|
||||
author: number | User;
|
||||
featuredImage?: (number | null) | Media;
|
||||
publishedAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ("draft" | "published") | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages".
|
||||
@@ -258,6 +203,25 @@ export interface Page {
|
||||
createdAt: string;
|
||||
_status?: ("draft" | "published") | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
@@ -286,10 +250,6 @@ export interface PayloadLockedDocument {
|
||||
relationTo: "users";
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "articles";
|
||||
value: number | Article;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "pages";
|
||||
value: number | Page;
|
||||
@@ -365,22 +325,6 @@ export interface UsersSelect<T extends boolean = true> {
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "articles_select".
|
||||
*/
|
||||
export interface ArticlesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
content?: T;
|
||||
status?: T;
|
||||
author?: T;
|
||||
featuredImage?: T;
|
||||
publishedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages_select".
|
||||
|
||||
@@ -5,16 +5,12 @@ describe("payloadConfig composition", () => {
|
||||
it("registers all feature collections", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["users", "articles", "pages", "media"]),
|
||||
);
|
||||
expect(slugs).toEqual(expect.arrayContaining(["users", "pages", "media"]));
|
||||
});
|
||||
|
||||
it("registers all feature globals", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||
expect(slugs).toEqual(
|
||||
expect.arrayContaining(["site-settings", "header"]),
|
||||
);
|
||||
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { users } from "@repo/auth/cms";
|
||||
import { articles } from "@repo/blog/cms";
|
||||
import { media } from "@repo/media/cms";
|
||||
import { pages, siteSettings } from "@repo/marketing-pages/cms";
|
||||
import { header } from "@repo/navigation/cms";
|
||||
@@ -15,7 +14,7 @@ const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
editor: lexicalEditor(),
|
||||
collections: [users, articles, pages, media],
|
||||
collections: [users, pages, media],
|
||||
globals: [siteSettings, header],
|
||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||
db: postgresAdapter({
|
||||
|
||||
@@ -5,24 +5,42 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const FEATURES = ["auth", "blog", "media", "marketing-pages", "navigation"];
|
||||
const FEATURES = ["auth", "media", "marketing-pages", "navigation"];
|
||||
const ANCHORS = {
|
||||
"src/index.ts": ["// <gen:events>", "// <gen:realtime-channels>"],
|
||||
"src/di/symbols.ts": ["// <gen:event-handler-symbols>", "// <gen:job-symbols>", "// <gen:realtime-handler-symbols>"],
|
||||
"src/di/bind-production.ts": ["// <gen:event-handlers>", "// <gen:jobs>", "// <gen:realtime-handlers>"],
|
||||
"src/di/bind-dev-seed.ts": ["// <gen:event-handlers>", "// <gen:jobs>", "// <gen:realtime-handlers>"],
|
||||
"src/di/symbols.ts": [
|
||||
"// <gen:event-handler-symbols>",
|
||||
"// <gen:job-symbols>",
|
||||
"// <gen:realtime-handler-symbols>",
|
||||
],
|
||||
"src/di/bind-production.ts": [
|
||||
"// <gen:event-handlers>",
|
||||
"// <gen:jobs>",
|
||||
"// <gen:realtime-handlers>",
|
||||
],
|
||||
"src/di/bind-dev-seed.ts": [
|
||||
"// <gen:event-handlers>",
|
||||
"// <gen:jobs>",
|
||||
"// <gen:realtime-handlers>",
|
||||
],
|
||||
"src/integrations/cms/index.ts": ["// <gen:job-tasks>"],
|
||||
};
|
||||
|
||||
describe("// <gen:*> anchor presence in core-eslint/base.js", () => {
|
||||
it("base.js contains realtime-rules-imports anchor", () => {
|
||||
const baseSource = readFileSync(join(REPO_ROOT, "packages/core-eslint/base.js"), "utf8");
|
||||
const baseSource = readFileSync(
|
||||
join(REPO_ROOT, "packages/core-eslint/base.js"),
|
||||
"utf8",
|
||||
);
|
||||
expect(baseSource).toContain("// <gen:realtime-rules-imports>");
|
||||
expect(baseSource).toContain("// <gen:realtime-rules>");
|
||||
});
|
||||
|
||||
it("base.js contains events-rules anchor", () => {
|
||||
const baseSource = readFileSync(join(REPO_ROOT, "packages/core-eslint/base.js"), "utf8");
|
||||
const baseSource = readFileSync(
|
||||
join(REPO_ROOT, "packages/core-eslint/base.js"),
|
||||
"utf8",
|
||||
);
|
||||
expect(baseSource).toContain("// <gen:events-rules>");
|
||||
});
|
||||
});
|
||||
|
||||
64
pnpm-lock.yaml
generated
64
pnpm-lock.yaml
generated
@@ -176,9 +176,6 @@ importers:
|
||||
"@repo/auth":
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/auth
|
||||
"@repo/blog":
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/blog
|
||||
"@repo/core-api":
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core-api
|
||||
@@ -320,61 +317,6 @@ importers:
|
||||
specifier: ^3.1.0
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
|
||||
|
||||
packages/blog:
|
||||
dependencies:
|
||||
"@repo/core-shared":
|
||||
specifier: workspace:*
|
||||
version: link:../core-shared
|
||||
"@repo/core-trpc":
|
||||
specifier: workspace:^
|
||||
version: link:../core-trpc
|
||||
"@tanstack/react-query":
|
||||
specifier: ^5.66.0
|
||||
version: 5.96.2(react@19.2.4)
|
||||
"@trpc/client":
|
||||
specifier: ^11.17.0
|
||||
version: 11.17.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
"@trpc/server":
|
||||
specifier: ^11.0.0
|
||||
version: 11.16.0(typescript@5.9.3)
|
||||
inversify:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.2(reflect-metadata@0.2.2)
|
||||
payload:
|
||||
specifier: ^3.14.0
|
||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
zod:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
"@repo/core-eslint":
|
||||
specifier: workspace:*
|
||||
version: link:../core-eslint
|
||||
"@repo/core-testing":
|
||||
specifier: workspace:*
|
||||
version: link:../core-testing
|
||||
"@repo/core-typescript":
|
||||
specifier: workspace:*
|
||||
version: link:../core-typescript
|
||||
"@types/node":
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.17
|
||||
"@types/react":
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.14
|
||||
"@vitest/coverage-v8":
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vitest:
|
||||
specifier: ^3.1.0
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
|
||||
|
||||
packages/core-analytics:
|
||||
dependencies:
|
||||
"@repo/core-shared":
|
||||
@@ -417,9 +359,6 @@ importers:
|
||||
"@repo/auth":
|
||||
specifier: workspace:*
|
||||
version: link:../auth
|
||||
"@repo/blog":
|
||||
specifier: workspace:*
|
||||
version: link:../blog
|
||||
"@repo/core-consent":
|
||||
specifier: workspace:*
|
||||
version: link:../core-consent
|
||||
@@ -524,9 +463,6 @@ importers:
|
||||
"@repo/auth":
|
||||
specifier: workspace:*
|
||||
version: link:../auth
|
||||
"@repo/blog":
|
||||
specifier: workspace:*
|
||||
version: link:../blog
|
||||
"@repo/marketing-pages":
|
||||
specifier: workspace:*
|
||||
version: link:../marketing-pages
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@repo/core-shared": ["./packages/core-shared/src/index.ts"],
|
||||
"@repo/core-shared/payload": ["./packages/core-shared/src/payload/index.ts"],
|
||||
"@repo/core-shared/trpc/init": ["./packages/core-shared/src/trpc/init.ts"],
|
||||
"@repo/core-shared/trpc/context": ["./packages/core-shared/src/trpc/context.ts"],
|
||||
"@repo/core-shared/payload": [
|
||||
"./packages/core-shared/src/payload/index.ts"
|
||||
],
|
||||
"@repo/core-shared/trpc/init": [
|
||||
"./packages/core-shared/src/trpc/init.ts"
|
||||
],
|
||||
"@repo/core-shared/trpc/context": [
|
||||
"./packages/core-shared/src/trpc/context.ts"
|
||||
],
|
||||
"@repo/core-cms": ["./packages/core-cms/src/index.ts"],
|
||||
"@repo/core-cms/generated-types": ["./packages/core-cms/src/generated-types.ts"],
|
||||
"@repo/blog": ["./packages/blog/src/index.ts"],
|
||||
"@repo/blog/cms": ["./packages/blog/src/integrations/cms/index.ts"],
|
||||
"@repo/blog/api": ["./packages/blog/src/integrations/api/router.ts"],
|
||||
"@repo/core-cms/generated-types": [
|
||||
"./packages/core-cms/src/generated-types.ts"
|
||||
],
|
||||
"@repo/core-api": ["./packages/core-api/src/index.ts"],
|
||||
"@repo/core-trpc": ["./packages/core-trpc/src/index.ts"],
|
||||
"@repo/core-ui": ["./packages/core-ui/src/index.ts"],
|
||||
@@ -20,20 +25,42 @@
|
||||
"@repo/media": ["./packages/media/src/index.ts"],
|
||||
"@repo/media/cms": ["./packages/media/src/integrations/cms/index.ts"],
|
||||
"@repo/media/api": ["./packages/media/src/integrations/api/index.ts"],
|
||||
"@repo/media/di/bind-production": ["./packages/media/src/di/bind-production.ts"],
|
||||
"@repo/media/di/bind-production": [
|
||||
"./packages/media/src/di/bind-production.ts"
|
||||
],
|
||||
"@repo/marketing-pages": ["./packages/marketing-pages/src/index.ts"],
|
||||
"@repo/marketing-pages/cms": ["./packages/marketing-pages/src/integrations/cms/index.ts"],
|
||||
"@repo/marketing-pages/api": ["./packages/marketing-pages/src/integrations/api/router.ts"],
|
||||
"@repo/marketing-pages/cms": [
|
||||
"./packages/marketing-pages/src/integrations/cms/index.ts"
|
||||
],
|
||||
"@repo/marketing-pages/api": [
|
||||
"./packages/marketing-pages/src/integrations/api/router.ts"
|
||||
],
|
||||
"@repo/navigation": ["./packages/navigation/src/index.ts"],
|
||||
"@repo/navigation/cms": ["./packages/navigation/src/integrations/cms/index.ts"],
|
||||
"@repo/navigation/api": ["./packages/navigation/src/integrations/api/router.ts"],
|
||||
"@repo/navigation/cms": [
|
||||
"./packages/navigation/src/integrations/cms/index.ts"
|
||||
],
|
||||
"@repo/navigation/api": [
|
||||
"./packages/navigation/src/integrations/api/router.ts"
|
||||
],
|
||||
"@repo/core-testing": ["./packages/core-testing/src/index.ts"],
|
||||
"@repo/core-testing/factory": ["./packages/core-testing/src/factory/index.ts"],
|
||||
"@repo/core-testing/contract": ["./packages/core-testing/src/contract/index.ts"],
|
||||
"@repo/core-testing/react": ["./packages/core-testing/src/react/index.ts"],
|
||||
"@repo/core-testing/payload": ["./packages/core-testing/src/payload/index.ts"],
|
||||
"@repo/core-testing/setup/jsdom": ["./packages/core-testing/src/setup/jsdom.ts"],
|
||||
"@repo/core-testing/setup/node": ["./packages/core-testing/src/setup/node.ts"]
|
||||
"@repo/core-testing/factory": [
|
||||
"./packages/core-testing/src/factory/index.ts"
|
||||
],
|
||||
"@repo/core-testing/contract": [
|
||||
"./packages/core-testing/src/contract/index.ts"
|
||||
],
|
||||
"@repo/core-testing/react": [
|
||||
"./packages/core-testing/src/react/index.ts"
|
||||
],
|
||||
"@repo/core-testing/payload": [
|
||||
"./packages/core-testing/src/payload/index.ts"
|
||||
],
|
||||
"@repo/core-testing/setup/jsdom": [
|
||||
"./packages/core-testing/src/setup/jsdom.ts"
|
||||
],
|
||||
"@repo/core-testing/setup/node": [
|
||||
"./packages/core-testing/src/setup/node.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ describe("e2e: core-package events", () => {
|
||||
// succeeds without the package being present (simulating the post-removal state).
|
||||
const featurePackages = [
|
||||
"auth",
|
||||
"blog",
|
||||
"media",
|
||||
"marketing-pages",
|
||||
"navigation",
|
||||
|
||||
@@ -63,11 +63,10 @@ describe("e2e: core-package trpc", () => {
|
||||
|
||||
// Strip @repo/core-trpc from every package that references it so pnpm
|
||||
// install succeeds without the package being present (simulating the
|
||||
// post-removal state). Apps list it directly; the blog, marketing-pages,
|
||||
// and navigation features depend on it for their ./ui tRPC hooks.
|
||||
// post-removal state). Apps list it directly; the marketing-pages and
|
||||
// navigation features depend on it for their ./ui tRPC hooks.
|
||||
for (const pkgDir of [
|
||||
["apps", "web-next"],
|
||||
["packages", "blog"],
|
||||
["packages", "marketing-pages"],
|
||||
["packages", "navigation"],
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user