diff --git a/packages/blog/src/__contracts__/articles-repository.contract.ts b/packages/blog/src/__contracts__/articles-repository.contract.ts index 891f62a..3273514 100644 --- a/packages/blog/src/__contracts__/articles-repository.contract.ts +++ b/packages/blog/src/__contracts__/articles-repository.contract.ts @@ -94,6 +94,46 @@ export const articlesRepositoryContract = expect(result[0]?.authorId).toBe("author-a"); }); + it("getArticles returns the exact window for an aligned offset", async () => { + const created = []; + for (let i = 0; i < 5; i++) { + created.push(await repo.createArticle(articleFactory.build())); + } + const window = await repo.getArticles({ offset: 2, limit: 2 }); + expect(window.map((a) => a.id)).toEqual([ + created[2]?.id, + created[3]?.id, + ]); + }); + + it("getArticles returns the exact window for a non-aligned offset", async () => { + const created = []; + for (let i = 0; i < 5; i++) { + created.push(await repo.createArticle(articleFactory.build())); + } + // offset 3 with limit 2 straddles two limit-sized pages + const window = await repo.getArticles({ offset: 3, limit: 2 }); + expect(window.map((a) => a.id)).toEqual([ + created[3]?.id, + created[4]?.id, + ]); + }); + + it("getArticles non-aligned offset near the end returns only the remaining items", async () => { + const created = []; + for (let i = 0; i < 5; i++) { + created.push(await repo.createArticle(articleFactory.build())); + } + const window = await repo.getArticles({ offset: 4, limit: 3 }); + expect(window.map((a) => a.id)).toEqual([created[4]?.id]); + }); + + it("getArticles offset past the end returns an empty array", async () => { + await repo.createArticle(articleFactory.build()); + const window = await repo.getArticles({ offset: 7, limit: 3 }); + expect(window).toEqual([]); + }); + // --- updateArticle --- it("updateArticle changes fields and returns updated article", async () => { diff --git a/packages/blog/src/infrastructure/repositories/articles.repository.test.ts b/packages/blog/src/infrastructure/repositories/articles.repository.test.ts index b57b55b..8fe28bb 100644 --- a/packages/blog/src/infrastructure/repositories/articles.repository.test.ts +++ b/packages/blog/src/infrastructure/repositories/articles.repository.test.ts @@ -33,6 +33,7 @@ function buildPayloadStub() { async ({ where, limit, + page, }: { collection: string; where?: { @@ -54,17 +55,27 @@ function buildPayloadStub() { if (where?.author) { docs = docs.filter((d) => d.author === where.author?.equals); } - if (limit !== undefined) { - docs = docs.slice(0, limit); - } + // Mirror real Payload pagination: page-based windows of size `limit`. + const lim = limit ?? 50; + const pg = page ?? 1; + const start = (pg - 1) * lim; + docs = docs.slice(start, start + lim); return { docs }; }, ), findByID: vi.fn( - async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => { + 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 }); + const err = Object.assign(new Error(`Not found: ${id}`), { + status: 404, + }); throw err; } return doc; @@ -82,7 +93,9 @@ function buildPayloadStub() { }) => { const existing = store.get(String(id)); if (!existing) { - const err = Object.assign(new Error(`Not found: ${id}`), { status: 404 }); + const err = Object.assign(new Error(`Not found: ${id}`), { + status: 404, + }); throw err; } const updated = { ...existing, ...data }; diff --git a/packages/blog/src/infrastructure/repositories/articles.repository.ts b/packages/blog/src/infrastructure/repositories/articles.repository.ts index b0c7074..27f7d2c 100644 --- a/packages/blog/src/infrastructure/repositories/articles.repository.ts +++ b/packages/blog/src/infrastructure/repositories/articles.repository.ts @@ -88,7 +88,10 @@ export class ArticlesRepository implements IArticlesRepository { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "getArticle" }, }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); throw err; } }, @@ -97,7 +100,11 @@ export class ArticlesRepository implements IArticlesRepository { async getArticleBySlug(slug: string): Promise
{ return this.tracer.startSpan( - { name: "articles.getArticleBySlug", op: "repository", attributes: { slug } }, + { + name: "articles.getArticleBySlug", + op: "repository", + attributes: { slug }, + }, async (span) => { try { const payload = await getPayload({ config: this.config }); @@ -114,7 +121,10 @@ export class ArticlesRepository implements IArticlesRepository { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" }, }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); throw err; } }, @@ -145,22 +155,42 @@ export class ArticlesRepository implements IArticlesRepository { 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)); + const limit = options?.limit ?? 50; + const offset = options?.offset ?? 0; + // Payload paginates by page, not offset. A non-aligned offset + // (offset % limit !== 0) straddles two pages, so fetch both and + // slice out the exact [offset, offset + limit) window. + const page = limit > 0 ? Math.floor(offset / limit) + 1 : 1; + const remainder = limit > 0 ? offset % limit : 0; + + const findPage = (p: number) => + payload.find({ + collection: "articles", + where: where as never, + limit, + page: p, + overrideAccess: true, + }); + + const first = await findPage(page); + let docs = first.docs; + if (remainder > 0) { + if (docs.length === limit) { + const second = await findPage(page + 1); + docs = [...docs, ...second.docs]; + } + docs = docs.slice(remainder, remainder + limit); + } + span.setAttribute("count", docs.length); + return 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)); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); throw err; } }, @@ -169,7 +199,11 @@ export class ArticlesRepository implements IArticlesRepository { async createArticle(input: Article): Promise
{ return this.tracer.startSpan( - { name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } }, + { + name: "articles.createArticle", + op: "repository", + attributes: { slug: input.slug }, + }, async (span) => { try { const payload = await getPayload({ config: this.config }); @@ -190,7 +224,10 @@ export class ArticlesRepository implements IArticlesRepository { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "createArticle" }, }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); throw err; } }, @@ -233,7 +270,10 @@ export class ArticlesRepository implements IArticlesRepository { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "updateArticle" }, }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); throw err; } },