Initial commit
This commit is contained in:
178
packages/blog/src/__contracts__/articles-repository.contract.ts
Normal file
178
packages/blog/src/__contracts__/articles-repository.contract.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { it, expect, beforeEach, describe } from "vitest";
|
||||
import { defineContractSuite } from "@repo/core-testing/contract";
|
||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||
import { articleFactory } from "../__factories__/article.factory";
|
||||
|
||||
export const articlesRepositoryContract =
|
||||
defineContractSuite<IArticlesRepository>(
|
||||
"IArticlesRepository",
|
||||
({ buildSubject, getTracer }) => {
|
||||
let repo: IArticlesRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
articleFactory.reset();
|
||||
repo = await buildSubject();
|
||||
});
|
||||
|
||||
// --- createArticle ---
|
||||
|
||||
it("createArticle returns an article with an id and the correct fields", async () => {
|
||||
const seed = articleFactory.build({ title: "Hello World" });
|
||||
const created = await repo.createArticle(seed);
|
||||
// Implementations may assign their own id (e.g. Payload), so we only
|
||||
// verify the id is a non-empty string and the other fields match.
|
||||
expect(typeof created.id).toBe("string");
|
||||
expect(created.id.length).toBeGreaterThan(0);
|
||||
expect(created.title).toBe("Hello World");
|
||||
expect(created.slug).toBe(seed.slug);
|
||||
expect(created.status).toBe(seed.status);
|
||||
expect(created.authorId).toBe(seed.authorId);
|
||||
});
|
||||
|
||||
// --- getArticle ---
|
||||
|
||||
it("createArticle then getArticle returns it by the returned id", async () => {
|
||||
const seed = articleFactory.build();
|
||||
const created = await repo.createArticle(seed);
|
||||
// Use the id returned by createArticle (Payload may differ from seed.id)
|
||||
const result = await repo.getArticle(created.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(created.id);
|
||||
expect(result?.slug).toBe(seed.slug);
|
||||
});
|
||||
|
||||
it("getArticle returns undefined for missing id", async () => {
|
||||
expect(await repo.getArticle("does-not-exist")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getArticleBySlug ---
|
||||
|
||||
it("createArticle then getArticleBySlug returns it by slug", async () => {
|
||||
const seed = articleFactory.build({ slug: "my-slug" });
|
||||
const created = await repo.createArticle(seed);
|
||||
const result = await repo.getArticleBySlug("my-slug");
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(created.id);
|
||||
expect(result?.slug).toBe("my-slug");
|
||||
});
|
||||
|
||||
it("getArticleBySlug returns undefined for missing slug", async () => {
|
||||
expect(await repo.getArticleBySlug("does-not-exist")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- getArticles ---
|
||||
|
||||
it("getArticles returns empty array when no articles", async () => {
|
||||
const list = await repo.getArticles();
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getArticles returns all articles when no filter", async () => {
|
||||
await repo.createArticle(articleFactory.build());
|
||||
await repo.createArticle(articleFactory.build());
|
||||
const list = await repo.getArticles();
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("getArticles filters by status", async () => {
|
||||
await repo.createArticle(articleFactory.build({ status: "draft" }));
|
||||
await repo.createArticle(articleFactory.build({ status: "published" }));
|
||||
const drafts = await repo.getArticles({ status: "draft" });
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0]?.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("getArticles filters by authorId", async () => {
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ authorId: "author-a" }),
|
||||
);
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ authorId: "author-b" }),
|
||||
);
|
||||
const result = await repo.getArticles({ authorId: "author-a" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.authorId).toBe("author-a");
|
||||
});
|
||||
|
||||
// --- updateArticle ---
|
||||
|
||||
it("updateArticle changes fields and returns updated article", async () => {
|
||||
const seed = articleFactory.build({ status: "draft" });
|
||||
const created = await repo.createArticle(seed);
|
||||
// Use the returned id for the update lookup
|
||||
const updated = await repo.updateArticle(created.id, {
|
||||
status: "published",
|
||||
});
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.id).toBe(created.id);
|
||||
expect(updated?.status).toBe("published");
|
||||
});
|
||||
|
||||
it("updateArticle returns undefined for missing id", async () => {
|
||||
const result = await repo.updateArticle("no-such-id", {
|
||||
title: "new",
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("span emission", () => {
|
||||
it("getArticles emits articles.getArticles span with op=repository", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getArticles({ limit: 5 });
|
||||
const span = tracer.findSpan("articles.getArticles");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.limit).toBe(5);
|
||||
});
|
||||
|
||||
it("getArticle emits articles.getArticle span with id attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getArticle("nonexistent");
|
||||
const span = tracer.findSpan("articles.getArticle");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe("nonexistent");
|
||||
});
|
||||
|
||||
it("getArticleBySlug emits articles.getArticleBySlug span with slug attribute", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
await repo.getArticleBySlug("nonexistent");
|
||||
const span = tracer.findSpan("articles.getArticleBySlug");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.slug).toBe("nonexistent");
|
||||
});
|
||||
|
||||
it("createArticle emits articles.createArticle span", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
const seed = articleFactory.build();
|
||||
await repo.createArticle(seed);
|
||||
const span = tracer.findSpan("articles.createArticle");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.slug).toBe(seed.slug);
|
||||
});
|
||||
|
||||
it("updateArticle emits articles.updateArticle span", async () => {
|
||||
if (!getTracer) return;
|
||||
const tracer = getTracer();
|
||||
tracer.reset();
|
||||
const seed = articleFactory.build();
|
||||
const created = await repo.createArticle(seed);
|
||||
await repo.updateArticle(created.id, { title: "Updated" });
|
||||
const span = tracer.findSpan("articles.updateArticle");
|
||||
expect(span).toBeDefined();
|
||||
expect(span!.op).toBe("repository");
|
||||
expect(span!.attributes.id).toBe(created.id);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
27
packages/blog/src/__factories__/article.factory.test.ts
Normal file
27
packages/blog/src/__factories__/article.factory.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
describe("articleFactory", () => {
|
||||
beforeEach(() => articleFactory.reset());
|
||||
|
||||
it("returns an Article with stable defaults", () => {
|
||||
const a = articleFactory.build();
|
||||
expect(a.title).toBe("Article 1");
|
||||
expect(a.slug).toBe("article-1");
|
||||
expect(a.status).toBe("draft");
|
||||
expect(a.createdAt).toEqual(new Date("2026-01-01T00:00:00Z"));
|
||||
});
|
||||
|
||||
it("applies overrides", () => {
|
||||
const a = articleFactory.build({ status: "published", title: "X" });
|
||||
expect(a.status).toBe("published");
|
||||
expect(a.title).toBe("X");
|
||||
});
|
||||
|
||||
it("increments sequence per build", () => {
|
||||
const a = articleFactory.build();
|
||||
const b = articleFactory.build();
|
||||
expect(a.id).toBe("article-1");
|
||||
expect(b.id).toBe("article-2");
|
||||
});
|
||||
});
|
||||
13
packages/blog/src/__factories__/article.factory.ts
Normal file
13
packages/blog/src/__factories__/article.factory.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineFactory } from "@repo/core-testing/factory";
|
||||
import type { Article } from "../entities/models/article";
|
||||
|
||||
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
|
||||
id: `article-${sequence}`,
|
||||
title: `Article ${sequence}`,
|
||||
slug: `article-${sequence}`,
|
||||
content: null,
|
||||
status: "draft",
|
||||
authorId: "user-1",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
}));
|
||||
1
packages/blog/src/__factories__/index.ts
Normal file
1
packages/blog/src/__factories__/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { articleFactory } from "./article.factory";
|
||||
35
packages/blog/src/__seeds__/dev.ts
Normal file
35
packages/blog/src/__seeds__/dev.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { articleFactory } from "../__factories__/article.factory";
|
||||
import type { Article } from "../entities/models/article";
|
||||
|
||||
/**
|
||||
* Realistic blog seed for dev mode + storybook stories.
|
||||
*
|
||||
* Built from `articleFactory` so factory defaults take care of the boring
|
||||
* fields (createdAt, updatedAt, content, authorId) and we only override what
|
||||
* makes the data look like a populated database.
|
||||
*
|
||||
* Lazily produced so importing this module is side-effect-free — the factory's
|
||||
* sequence counter only advances when a binder calls `buildDevArticles()`.
|
||||
*/
|
||||
export function buildDevArticles(): Article[] {
|
||||
return [
|
||||
articleFactory.build({
|
||||
id: "welcome",
|
||||
slug: "welcome",
|
||||
title: "Welcome to the blog",
|
||||
status: "published",
|
||||
}),
|
||||
articleFactory.build({
|
||||
id: "vertical-feature-architecture",
|
||||
slug: "vertical-feature-architecture",
|
||||
title: "Why vertical-feature packages",
|
||||
status: "published",
|
||||
}),
|
||||
articleFactory.build({
|
||||
id: "wip-post",
|
||||
slug: "work-in-progress",
|
||||
title: "A draft we haven't shipped yet",
|
||||
status: "draft",
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Article } from "../../entities/models/article";
|
||||
|
||||
export interface IArticlesRepository {
|
||||
getArticle(id: string): Promise<Article | undefined>;
|
||||
getArticleBySlug(slug: string): Promise<Article | undefined>;
|
||||
getArticles(options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]>;
|
||||
createArticle(input: Article): Promise<Article>;
|
||||
updateArticle(
|
||||
id: string,
|
||||
input: Partial<Article>,
|
||||
): Promise<Article | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
createArticleUseCase,
|
||||
createArticleOutputSchema,
|
||||
} from "@/application/use-cases/create-article.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
describe("createArticleUseCase", () => {
|
||||
it("creates an article in draft status with auto-generated slug", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = createArticleUseCase(repo);
|
||||
|
||||
const result = await useCase({
|
||||
title: "Hello World",
|
||||
content: "body",
|
||||
authorId: "u1",
|
||||
});
|
||||
expect(result.title).toBe("Hello World");
|
||||
expect(result.slug).toBe("hello-world");
|
||||
expect(result.status).toBe("draft");
|
||||
expect(result.id).toBeTruthy();
|
||||
|
||||
const stored = await repo.getArticle(result.id);
|
||||
expect(stored).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses provided slug when supplied", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = createArticleUseCase(repo);
|
||||
|
||||
const result = await useCase({
|
||||
title: "Whatever",
|
||||
content: "body",
|
||||
authorId: "u1",
|
||||
slug: "custom-slug",
|
||||
});
|
||||
expect(result.slug).toBe("custom-slug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createArticleUseCase output validation", () => {
|
||||
it("throws when repository returns a malformed article", async () => {
|
||||
const repo = {
|
||||
createArticle: async () => ({ id: 1 }) as unknown as never,
|
||||
} as unknown as IArticlesRepository;
|
||||
const useCase = createArticleUseCase(repo);
|
||||
await expect(
|
||||
useCase({ title: "X", authorId: "u1" }),
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that accepts a valid article shape", () => {
|
||||
expect(createArticleOutputSchema).toBeDefined();
|
||||
const result = createArticleOutputSchema.safeParse({
|
||||
id: "a1",
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
content: null,
|
||||
status: "draft",
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { articleSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const createArticleInputSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.unknown().optional(),
|
||||
authorId: z.string(),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type CreateArticleInput = z.infer<typeof createArticleInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const createArticleOutputSchema = articleSchema;
|
||||
export type CreateArticleOutput = z.infer<typeof createArticleOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
|
||||
|
||||
export const createArticleUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: CreateArticleInput): Promise<CreateArticleOutput> => {
|
||||
const now = new Date();
|
||||
const article = {
|
||||
id: crypto.randomUUID(),
|
||||
title: input.title,
|
||||
slug: input.slug ?? generateSlug(input.title),
|
||||
content: input.content ?? null,
|
||||
status: "draft" as const,
|
||||
authorId: input.authorId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const result = await articlesRepository.createArticle(article);
|
||||
return createArticleOutputSchema.parse(result);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
getArticleBySlugUseCase,
|
||||
getArticleBySlugOutputSchema,
|
||||
} from "@/application/use-cases/get-article-by-slug.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { ArticleNotFoundError } from "@/entities/errors/article";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
describe("getArticleBySlugUseCase", () => {
|
||||
it("returns the article when slug exists", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const seed = articleFactory.build({ slug: "test-slug" });
|
||||
await repo.createArticle(seed);
|
||||
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const result = await useCase({ slug: "test-slug" });
|
||||
|
||||
expect(result?.slug).toBe("test-slug");
|
||||
});
|
||||
|
||||
it("throws ArticleNotFoundError when slug is missing", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(
|
||||
ArticleNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArticleBySlugUseCase output validation", () => {
|
||||
it("throws when repository returns a malformed article", async () => {
|
||||
const repo = {
|
||||
getArticleBySlug: async () => ({ id: 123 }) as unknown as never,
|
||||
} as unknown as IArticlesRepository;
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
await expect(useCase({ slug: "test" })).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that accepts a valid article shape", () => {
|
||||
expect(getArticleBySlugOutputSchema).toBeDefined();
|
||||
const result = getArticleBySlugOutputSchema.safeParse({
|
||||
id: "a1",
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
content: null,
|
||||
status: "draft",
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||||
import { articleSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const getArticleBySlugInputSchema = z
|
||||
.object({ slug: z.string().min(1) })
|
||||
.strict();
|
||||
export type GetArticleBySlugInput = z.infer<typeof getArticleBySlugInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const getArticleBySlugOutputSchema = articleSchema;
|
||||
export type GetArticleBySlugOutput = z.infer<typeof getArticleBySlugOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;
|
||||
|
||||
export const getArticleBySlugUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: GetArticleBySlugInput): Promise<GetArticleBySlugOutput> => {
|
||||
const article = await articlesRepository.getArticleBySlug(input.slug);
|
||||
if (!article) {
|
||||
throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`);
|
||||
}
|
||||
return getArticleBySlugOutputSchema.parse(article);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
getArticlesUseCase,
|
||||
getArticlesOutputSchema,
|
||||
} from "@/application/use-cases/get-articles.use-case";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { articleFactory } from "@/__factories__/article.factory";
|
||||
|
||||
describe("getArticlesUseCase", () => {
|
||||
it("returns all articles with no filters", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ id: "1", title: "A", slug: "a" }),
|
||||
);
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const result = await useCase({});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("1");
|
||||
});
|
||||
|
||||
it("filters by status", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
articleFactory.reset();
|
||||
await repo.createArticle(
|
||||
articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }),
|
||||
);
|
||||
await repo.createArticle(
|
||||
articleFactory.build({
|
||||
id: "2",
|
||||
title: "B",
|
||||
slug: "b",
|
||||
status: "published",
|
||||
}),
|
||||
);
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
const result = await useCase({ status: "published" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArticlesUseCase output validation", () => {
|
||||
it("throws when the repository returns a malformed article", async () => {
|
||||
const repo = new MockArticlesRepository();
|
||||
// bypass the mock's createArticle (which is typed) by reaching into _articles directly
|
||||
(repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 });
|
||||
|
||||
const useCase = getArticlesUseCase(repo);
|
||||
await expect(useCase({})).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors Article[]", () => {
|
||||
expect(getArticlesOutputSchema).toBeDefined();
|
||||
expect(getArticlesOutputSchema.safeParse([]).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { articleSchema, articleStatusSchema } from "../../entities/models/article";
|
||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const getArticlesInputSchema = z
|
||||
.object({
|
||||
status: articleStatusSchema.optional(),
|
||||
authorId: z.string().optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────
|
||||
export const getArticlesOutputSchema = z.array(articleSchema);
|
||||
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||||
|
||||
export const getArticlesUseCase =
|
||||
(articlesRepository: IArticlesRepository) =>
|
||||
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
|
||||
const result = await articlesRepository.getArticles(input);
|
||||
return getArticlesOutputSchema.parse(result);
|
||||
};
|
||||
76
packages/blog/src/di/bind-dev-seed.test.ts
Normal file
76
packages/blog/src/di/bind-dev-seed.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
|
||||
import { bindDevSeedBlog } from "@/di/bind-dev-seed";
|
||||
import { blogContainer } from "@/di/container";
|
||||
import { BLOG_SYMBOLS } from "@/di/symbols";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
describe("bindDevSeedBlog", () => {
|
||||
const tracer = new NoopTracer();
|
||||
const logger = new NoopLogger();
|
||||
|
||||
// Each test starts from the default empty-mock binding and tears down
|
||||
// afterwards so the global blogContainer state stays clean for siblings.
|
||||
beforeEach(() => {
|
||||
for (const sym of Object.values(BLOG_SYMBOLS)) {
|
||||
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||
}
|
||||
blogContainer
|
||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.to(MockArticlesRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const sym of Object.values(BLOG_SYMBOLS)) {
|
||||
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||
}
|
||||
blogContainer
|
||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.to(MockArticlesRepository);
|
||||
});
|
||||
|
||||
it("populates the repository with the dev articles", async () => {
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const all = await repo.getArticles();
|
||||
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("seeds the welcome article reachable by slug", async () => {
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
|
||||
const repo = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const welcome = await repo.getArticleBySlug("welcome");
|
||||
|
||||
expect(welcome).toBeDefined();
|
||||
expect(welcome?.title).toBe("Welcome to the blog");
|
||||
expect(welcome?.status).toBe("published");
|
||||
});
|
||||
|
||||
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const before = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const beforeCount = (await before.getArticles()).length;
|
||||
|
||||
await bindDevSeedBlog({ tracer, logger, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
|
||||
const after = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
const afterCount = (await after.getArticles()).length;
|
||||
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
// It's a fresh instance — not the previous one.
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
168
packages/blog/src/di/bind-dev-seed.ts
Normal file
168
packages/blog/src/di/bind-dev-seed.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { blogManifest } from "../feature.manifest";
|
||||
import { blogContainer } from "./container";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock";
|
||||
import { buildDevArticles } from "../__seeds__/dev";
|
||||
import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case";
|
||||
import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import { createArticleUseCase } from "../application/use-cases/create-article.use-case";
|
||||
import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller";
|
||||
import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
|
||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||
|
||||
/**
|
||||
* Replace the default empty mock with a populated one for dev mode + storybook.
|
||||
*
|
||||
* Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with
|
||||
* `bindProductionBlog(config)`. Tests must NOT call this — they construct
|
||||
* `new MockArticlesRepository()` directly and seed via factories per-test.
|
||||
*
|
||||
* Idempotent: safe to call multiple times; each call rebuilds a fresh
|
||||
* populated repo and rebinds the symbol.
|
||||
*/
|
||||
export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
|
||||
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
blogContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
blogContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
||||
}
|
||||
const repo = new MockArticlesRepository(tracer, logger);
|
||||
for (const article of buildDevArticles()) {
|
||||
await repo.createArticle(article);
|
||||
}
|
||||
blogContainer
|
||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.toConstantValue(repo);
|
||||
|
||||
// Use cases
|
||||
const wrappedGetArticles = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
factory: getArticlesUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticles",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedGetArticleBySlug = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
factory: getArticleBySlugUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticleBySlug",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedCreateArticle = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
factory: createArticleUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "createArticle",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
for (const sym of [
|
||||
BLOG_SYMBOLS.IGetArticlesController,
|
||||
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||||
BLOG_SYMBOLS.ICreateArticleController,
|
||||
]) {
|
||||
if (blogContainer.isBound(sym)) blogContainer.unbind(sym);
|
||||
}
|
||||
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticlesController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
|
||||
getArticlesController(wrappedGetArticles),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticleBySlugController).toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{
|
||||
feature: "blog",
|
||||
layer: "controller",
|
||||
name: "blog.getArticleBySlug",
|
||||
},
|
||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.ICreateArticleController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.createArticle", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.createArticle" },
|
||||
createArticleController(wrappedCreateArticle),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check (dev-seed mode).
|
||||
assertFeatureConformance(
|
||||
blogContainer,
|
||||
blogManifest,
|
||||
{
|
||||
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
17
packages/blog/src/di/bind-production.smoke.test.ts
Normal file
17
packages/blog/src/di/bind-production.smoke.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import "reflect-metadata";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||
import { bindProductionBlog } from "@/di/bind-production";
|
||||
|
||||
describe("bindProductionBlog — boot-time conformance", () => {
|
||||
it("binds every manifest use case through withSpan + withCapture", () => {
|
||||
expect(() =>
|
||||
bindProductionBlog({
|
||||
config: {} as SanitizedConfig,
|
||||
tracer: new NoopTracer(),
|
||||
logger: new NoopLogger(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
155
packages/blog/src/di/bind-production.ts
Normal file
155
packages/blog/src/di/bind-production.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
withSpan,
|
||||
withCapture,
|
||||
INSTRUMENTATION_SYMBOLS,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
assertFeatureConformance,
|
||||
wireUseCase,
|
||||
} from "@repo/core-shared/conformance";
|
||||
import { blogContainer } from "./container";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
import { blogManifest } from "../feature.manifest";
|
||||
import { ArticlesRepository } from "../infrastructure/repositories/articles.repository";
|
||||
import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case";
|
||||
import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import { createArticleUseCase } from "../application/use-cases/create-article.use-case";
|
||||
import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller";
|
||||
import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { createArticleController } from "../interface-adapters/controllers/create-article.controller";
|
||||
|
||||
export function bindProductionBlog(ctx: BindProductionContext): void {
|
||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
||||
ctx;
|
||||
|
||||
// Bind shared instrumentation into feature container
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
}
|
||||
if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
|
||||
blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
}
|
||||
blogContainer
|
||||
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
|
||||
.toConstantValue(tracer);
|
||||
blogContainer
|
||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||
.toConstantValue(logger);
|
||||
|
||||
// Real repository
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
||||
}
|
||||
const repo = new ArticlesRepository(config, tracer, logger);
|
||||
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
|
||||
|
||||
// Use cases
|
||||
const wrappedGetArticles = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
factory: getArticlesUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticles",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedGetArticleBySlug = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
factory: getArticleBySlugUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "getArticleBySlug",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
const wrappedCreateArticle = wireUseCase({
|
||||
container: blogContainer,
|
||||
symbol: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
factory: createArticleUseCase,
|
||||
deps: [repo],
|
||||
feature: "blog",
|
||||
layer: "use-case",
|
||||
name: "createArticle",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Controllers — wrapped with span at bind time
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IGetArticlesController);
|
||||
}
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticleBySlugController)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IGetArticleBySlugController);
|
||||
}
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.ICreateArticleController)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.ICreateArticleController);
|
||||
}
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.IGetArticlesController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticles", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.getArticles" },
|
||||
getArticlesController(wrappedGetArticles),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer.bind(BLOG_SYMBOLS.IGetArticleBySlugController).toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.getArticleBySlug", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{
|
||||
feature: "blog",
|
||||
layer: "controller",
|
||||
name: "blog.getArticleBySlug",
|
||||
},
|
||||
getArticleBySlugController(wrappedGetArticleBySlug),
|
||||
),
|
||||
),
|
||||
);
|
||||
blogContainer
|
||||
.bind(BLOG_SYMBOLS.ICreateArticleController)
|
||||
.toConstantValue(
|
||||
withSpan(
|
||||
tracer,
|
||||
{ name: "blog.createArticle", op: "controller" },
|
||||
withCapture(
|
||||
logger,
|
||||
{ feature: "blog", layer: "controller", name: "blog.createArticle" },
|
||||
createArticleController(wrappedCreateArticle),
|
||||
),
|
||||
),
|
||||
);
|
||||
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||
void bus;
|
||||
void queue;
|
||||
void realtime;
|
||||
void realtimeRegistry;
|
||||
// <gen:event-handlers>
|
||||
// <gen:jobs>
|
||||
// <gen:realtime-handlers>
|
||||
|
||||
// Boot-time conformance check.
|
||||
assertFeatureConformance(
|
||||
blogContainer,
|
||||
blogManifest,
|
||||
{
|
||||
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
39
packages/blog/src/di/container.test.ts
Normal file
39
packages/blog/src/di/container.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { blogContainer } from "./container";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
import { BlogModule } from "./module";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
describe("blogContainer", () => {
|
||||
beforeEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
blogContainer.load(BlogModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
blogContainer.unbindAll();
|
||||
});
|
||||
|
||||
it("resolves IArticlesRepository to MockArticlesRepository by default binding", () => {
|
||||
const repo = blogContainer.get<IArticlesRepository>(
|
||||
BLOG_SYMBOLS.IArticlesRepository,
|
||||
);
|
||||
expect(repo).toBeInstanceOf(MockArticlesRepository);
|
||||
});
|
||||
|
||||
it("resolves IGetArticlesController from the container", () => {
|
||||
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticlesController);
|
||||
expect(typeof ctrl).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves ICreateArticleController from the container", () => {
|
||||
const ctrl = blogContainer.get(BLOG_SYMBOLS.ICreateArticleController);
|
||||
expect(typeof ctrl).toBe("function");
|
||||
});
|
||||
|
||||
it("resolves IGetArticleBySlugController from the container", () => {
|
||||
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticleBySlugController);
|
||||
expect(typeof ctrl).toBe("function");
|
||||
});
|
||||
});
|
||||
6
packages/blog/src/di/container.ts
Normal file
6
packages/blog/src/di/container.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
import { BlogModule } from "./module";
|
||||
|
||||
export const blogContainer = new Container({ defaultScope: "Singleton" });
|
||||
blogContainer.load(BlogModule);
|
||||
69
packages/blog/src/di/module.ts
Normal file
69
packages/blog/src/di/module.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ContainerModule, type interfaces } from "inversify";
|
||||
|
||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock";
|
||||
import {
|
||||
getArticlesUseCase,
|
||||
type IGetArticlesUseCase,
|
||||
} from "../application/use-cases/get-articles.use-case";
|
||||
import {
|
||||
createArticleUseCase,
|
||||
type ICreateArticleUseCase,
|
||||
} from "../application/use-cases/create-article.use-case";
|
||||
import {
|
||||
getArticleBySlugUseCase,
|
||||
type IGetArticleBySlugUseCase,
|
||||
} from "../application/use-cases/get-article-by-slug.use-case";
|
||||
import {
|
||||
getArticlesController,
|
||||
type IGetArticlesController,
|
||||
} from "../interface-adapters/controllers/get-articles.controller";
|
||||
import {
|
||||
createArticleController,
|
||||
type ICreateArticleController,
|
||||
} from "../interface-adapters/controllers/create-article.controller";
|
||||
import {
|
||||
getArticleBySlugController,
|
||||
type IGetArticleBySlugController,
|
||||
} from "../interface-adapters/controllers/get-article-by-slug.controller";
|
||||
import { BLOG_SYMBOLS } from "./symbols";
|
||||
|
||||
export const BlogModule = new ContainerModule((bind: interfaces.Bind) => {
|
||||
bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository);
|
||||
|
||||
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue((ctx) =>
|
||||
getArticlesUseCase(
|
||||
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ICreateArticleUseCase>(BLOG_SYMBOLS.ICreateArticleUseCase).toDynamicValue((ctx) =>
|
||||
createArticleUseCase(
|
||||
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||
),
|
||||
);
|
||||
|
||||
bind<IGetArticleBySlugUseCase>(BLOG_SYMBOLS.IGetArticleBySlugUseCase).toDynamicValue((ctx) =>
|
||||
getArticleBySlugUseCase(
|
||||
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||
),
|
||||
);
|
||||
|
||||
bind<IGetArticlesController>(BLOG_SYMBOLS.IGetArticlesController).toDynamicValue((ctx) =>
|
||||
getArticlesController(
|
||||
ctx.container.get<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
bind<ICreateArticleController>(BLOG_SYMBOLS.ICreateArticleController).toDynamicValue((ctx) =>
|
||||
createArticleController(
|
||||
ctx.container.get<ICreateArticleUseCase>(BLOG_SYMBOLS.ICreateArticleUseCase),
|
||||
),
|
||||
);
|
||||
|
||||
bind<IGetArticleBySlugController>(BLOG_SYMBOLS.IGetArticleBySlugController).toDynamicValue((ctx) =>
|
||||
getArticleBySlugController(
|
||||
ctx.container.get<IGetArticleBySlugUseCase>(BLOG_SYMBOLS.IGetArticleBySlugUseCase),
|
||||
),
|
||||
);
|
||||
});
|
||||
14
packages/blog/src/di/symbols.ts
Normal file
14
packages/blog/src/di/symbols.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const BLOG_SYMBOLS = {
|
||||
IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
|
||||
// Use cases
|
||||
IGetArticlesUseCase: Symbol.for("blog:IGetArticlesUseCase"),
|
||||
ICreateArticleUseCase: Symbol.for("blog:ICreateArticleUseCase"),
|
||||
IGetArticleBySlugUseCase: Symbol.for("blog:IGetArticleBySlugUseCase"),
|
||||
// Controllers
|
||||
IGetArticlesController: Symbol.for("blog:IGetArticlesController"),
|
||||
ICreateArticleController: Symbol.for("blog:ICreateArticleController"),
|
||||
IGetArticleBySlugController: Symbol.for("blog:IGetArticleBySlugController"),
|
||||
// <gen:event-handler-symbols>
|
||||
// <gen:job-symbols>
|
||||
// <gen:realtime-handler-symbols>
|
||||
} as const;
|
||||
6
packages/blog/src/entities/errors/article.ts
Normal file
6
packages/blog/src/entities/errors/article.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class ArticleNotFoundError extends Error {
|
||||
constructor(message = "Article not found", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ArticleNotFoundError";
|
||||
}
|
||||
}
|
||||
6
packages/blog/src/entities/errors/common.ts
Normal file
6
packages/blog/src/entities/errors/common.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
24
packages/blog/src/entities/errors/errors.test.ts
Normal file
24
packages/blog/src/entities/errors/errors.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ArticleNotFoundError } from "./article";
|
||||
import { InputParseError } from "./common";
|
||||
|
||||
describe("ArticleNotFoundError", () => {
|
||||
it("uses the default message when none is given", () => {
|
||||
const err = new ArticleNotFoundError();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("Article not found");
|
||||
});
|
||||
|
||||
it("uses a custom message when provided", () => {
|
||||
const err = new ArticleNotFoundError("could not find article abc");
|
||||
expect(err.message).toBe("could not find article abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputParseError", () => {
|
||||
it("is an instance of Error with the given message", () => {
|
||||
const err = new InputParseError("invalid input");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe("invalid input");
|
||||
});
|
||||
});
|
||||
85
packages/blog/src/entities/models/article.test.ts
Normal file
85
packages/blog/src/entities/models/article.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { articleSchema, articleStatusSchema, type Article } from "./article";
|
||||
|
||||
describe("articleSchema", () => {
|
||||
it("accepts a minimal valid article with default status", () => {
|
||||
const result = articleSchema.parse({
|
||||
id: "abc",
|
||||
title: "Hello",
|
||||
slug: "hello",
|
||||
content: { type: "doc", children: [] },
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("accepts unknown rich-text content", () => {
|
||||
const result = articleSchema.parse({
|
||||
id: "abc",
|
||||
title: "Hello",
|
||||
slug: "hello",
|
||||
content: "any string is also fine",
|
||||
authorId: "u1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(result.content).toBe("any string is also fine");
|
||||
});
|
||||
|
||||
it("rejects empty title", () => {
|
||||
expect(() =>
|
||||
articleSchema.parse({
|
||||
id: "a",
|
||||
title: "",
|
||||
slug: "s",
|
||||
content: null,
|
||||
authorId: "u",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects title over 255 chars", () => {
|
||||
expect(() =>
|
||||
articleSchema.parse({
|
||||
id: "a",
|
||||
title: "x".repeat(256),
|
||||
slug: "s",
|
||||
content: null,
|
||||
authorId: "u",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("articleStatusSchema", () => {
|
||||
it("accepts 'draft' and 'published'", () => {
|
||||
expect(articleStatusSchema.parse("draft")).toBe("draft");
|
||||
expect(articleStatusSchema.parse("published")).toBe("published");
|
||||
});
|
||||
|
||||
it("rejects unknown status", () => {
|
||||
expect(() => articleStatusSchema.parse("archived")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Article type", () => {
|
||||
it("widens content to unknown", () => {
|
||||
const _example: Article = {
|
||||
id: "x",
|
||||
title: "t",
|
||||
slug: "s",
|
||||
content: { whatever: true },
|
||||
status: "draft",
|
||||
authorId: "u",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
expect(_example).toBeDefined();
|
||||
});
|
||||
});
|
||||
17
packages/blog/src/entities/models/article.ts
Normal file
17
packages/blog/src/entities/models/article.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const articleStatusSchema = z.enum(["draft", "published"]);
|
||||
|
||||
export const articleSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(255),
|
||||
content: z.unknown(),
|
||||
status: articleStatusSchema.default("draft"),
|
||||
authorId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type Article = z.infer<typeof articleSchema>;
|
||||
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
|
||||
52
packages/blog/src/feature.manifest.ts
Normal file
52
packages/blog/src/feature.manifest.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineFeature } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* The blog feature's conformance manifest.
|
||||
*/
|
||||
export const blogManifest = defineFeature({
|
||||
name: "blog",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
getArticles: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
getArticleBySlug: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
createArticle: {
|
||||
mutates: true,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
coverage: {
|
||||
bands: {
|
||||
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
|
||||
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
"use-cases": {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
controllers: {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
mutationTargets: ["entities", "use-cases"],
|
||||
},
|
||||
} as const);
|
||||
|
||||
export type BlogManifest = typeof blogManifest;
|
||||
36
packages/blog/src/index.ts
Normal file
36
packages/blog/src/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type { Article, ArticleStatus } from "./entities/models/article";
|
||||
export type { BlogRouter } from "./integrations/api/router";
|
||||
export { ArticleNotFoundError } from "./entities/errors/article";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
|
||||
// Use case schemas + types
|
||||
export {
|
||||
getArticlesInputSchema,
|
||||
getArticlesOutputSchema,
|
||||
type GetArticlesInput,
|
||||
type GetArticlesOutput,
|
||||
type IGetArticlesUseCase,
|
||||
} from "./application/use-cases/get-articles.use-case";
|
||||
export {
|
||||
createArticleInputSchema,
|
||||
createArticleOutputSchema,
|
||||
type CreateArticleInput,
|
||||
type CreateArticleOutput,
|
||||
type ICreateArticleUseCase,
|
||||
} from "./application/use-cases/create-article.use-case";
|
||||
export {
|
||||
getArticleBySlugInputSchema,
|
||||
getArticleBySlugOutputSchema,
|
||||
type GetArticleBySlugInput,
|
||||
type GetArticleBySlugOutput,
|
||||
type IGetArticleBySlugUseCase,
|
||||
} from "./application/use-cases/get-article-by-slug.use-case";
|
||||
|
||||
// Controller type aliases
|
||||
export type { IGetArticlesController } from "./interface-adapters/controllers/get-articles.controller";
|
||||
export type { ICreateArticleController } from "./interface-adapters/controllers/create-article.controller";
|
||||
export type { IGetArticleBySlugController } from "./interface-adapters/controllers/get-article-by-slug.controller";
|
||||
|
||||
// <gen:events>
|
||||
// <gen:realtime-channels>
|
||||
export { blogManifest, type BlogManifest } from "./feature.manifest";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe } from "vitest";
|
||||
import { RecordingTracer } from "@repo/core-testing/instrumentation";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||
import { articlesRepositoryContract } from "@/__contracts__/articles-repository.contract";
|
||||
|
||||
describe("MockArticlesRepository", () => {
|
||||
const tracer = new RecordingTracer();
|
||||
articlesRepositoryContract.run(
|
||||
() => new MockArticlesRepository(tracer),
|
||||
{ tracer: () => tracer },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/blog/src/integrations/api/procedures.ts
Normal file
12
packages/blog/src/integrations/api/procedures.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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"],
|
||||
]),
|
||||
);
|
||||
79
packages/blog/src/integrations/api/router.test.ts
Normal file
79
packages/blog/src/integrations/api/router.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
45
packages/blog/src/integrations/api/router.ts
Normal file
45
packages/blog/src/integrations/api/router.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
84
packages/blog/src/integrations/cms/collections/articles.ts
Normal file
84
packages/blog/src/integrations/cms/collections/articles.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
2
packages/blog/src/integrations/cms/index.ts
Normal file
2
packages/blog/src/integrations/cms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { articles } from "./collections/articles";
|
||||
// <gen:job-tasks>
|
||||
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
};
|
||||
23
packages/blog/src/ui/components/article-card.tsx
Normal file
23
packages/blog/src/ui/components/article-card.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
34
packages/blog/src/ui/components/article-detail.client.tsx
Normal file
34
packages/blog/src/ui/components/article-detail.client.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
24
packages/blog/src/ui/components/article-detail.server.tsx
Normal file
24
packages/blog/src/ui/components/article-detail.server.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
20
packages/blog/src/ui/components/article-list.client.tsx
Normal file
20
packages/blog/src/ui/components/article-list.client.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
24
packages/blog/src/ui/components/article-list.server.tsx
Normal file
24
packages/blog/src/ui/components/article-list.server.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
12
packages/blog/src/ui/hooks/use-article-by-slug.ts
Normal file
12
packages/blog/src/ui/hooks/use-article-by-slug.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
"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;
|
||||
};
|
||||
}
|
||||
18
packages/blog/src/ui/hooks/use-article-list.ts
Normal file
18
packages/blog/src/ui/hooks/use-article-list.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
"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[] };
|
||||
}
|
||||
6
packages/blog/src/ui/index.ts
Normal file
6
packages/blog/src/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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";
|
||||
34
packages/blog/src/ui/query.ts
Normal file
34
packages/blog/src/ui/query.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user