fix(blog): return the exact window for non-aligned offsets

Payload paginates by page, so floor(offset/limit)+1 alone returned the
wrong window whenever offset % limit != 0 (B9). Fetch the straddled
pages and slice the intra-page remainder. The test stub now honours
page like real Payload (it silently ignored it before), and the
contract pins aligned + non-aligned windows on mock and stub alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:29:06 +02:00
parent b66759a1ab
commit 50c30e9c1b
3 changed files with 117 additions and 24 deletions

View File

@@ -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 () => {

View File

@@ -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 };

View File

@@ -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<Article | undefined> {
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({
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: options?.limit ?? 50,
page: options?.offset
? Math.floor(options.offset / (options.limit ?? 50)) + 1
: 1,
limit,
page: p,
overrideAccess: true,
});
span.setAttribute("count", result.docs.length);
return result.docs.map((d) => mapDoc(d as PayloadArticleDoc));
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<Article> {
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;
}
},