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:
@@ -94,6 +94,46 @@ export const articlesRepositoryContract =
|
|||||||
expect(result[0]?.authorId).toBe("author-a");
|
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 ---
|
// --- updateArticle ---
|
||||||
|
|
||||||
it("updateArticle changes fields and returns updated article", async () => {
|
it("updateArticle changes fields and returns updated article", async () => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function buildPayloadStub() {
|
|||||||
async ({
|
async ({
|
||||||
where,
|
where,
|
||||||
limit,
|
limit,
|
||||||
|
page,
|
||||||
}: {
|
}: {
|
||||||
collection: string;
|
collection: string;
|
||||||
where?: {
|
where?: {
|
||||||
@@ -54,17 +55,27 @@ function buildPayloadStub() {
|
|||||||
if (where?.author) {
|
if (where?.author) {
|
||||||
docs = docs.filter((d) => d.author === where.author?.equals);
|
docs = docs.filter((d) => d.author === where.author?.equals);
|
||||||
}
|
}
|
||||||
if (limit !== undefined) {
|
// Mirror real Payload pagination: page-based windows of size `limit`.
|
||||||
docs = docs.slice(0, limit);
|
const lim = limit ?? 50;
|
||||||
}
|
const pg = page ?? 1;
|
||||||
|
const start = (pg - 1) * lim;
|
||||||
|
docs = docs.slice(start, start + lim);
|
||||||
return { docs };
|
return { docs };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
findByID: vi.fn(
|
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));
|
const doc = store.get(String(id));
|
||||||
if (!doc) {
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
return doc;
|
return doc;
|
||||||
@@ -82,7 +93,9 @@ function buildPayloadStub() {
|
|||||||
}) => {
|
}) => {
|
||||||
const existing = store.get(String(id));
|
const existing = store.get(String(id));
|
||||||
if (!existing) {
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
const updated = { ...existing, ...data };
|
const updated = { ...existing, ...data };
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getArticle" },
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -97,7 +100,11 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
|
|
||||||
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "articles.getArticleBySlug", op: "repository", attributes: { slug } },
|
{
|
||||||
|
name: "articles.getArticleBySlug",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { slug },
|
||||||
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
@@ -114,7 +121,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" },
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -145,22 +155,42 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
if (options?.status) where.status = { equals: options.status };
|
if (options?.status) where.status = { equals: options.status };
|
||||||
if (options?.authorId) where.author = { equals: options.authorId };
|
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",
|
collection: "articles",
|
||||||
where: where as never,
|
where: where as never,
|
||||||
limit: options?.limit ?? 50,
|
limit,
|
||||||
page: options?.offset
|
page: p,
|
||||||
? Math.floor(options.offset / (options.limit ?? 50)) + 1
|
|
||||||
: 1,
|
|
||||||
overrideAccess: true,
|
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) {
|
} catch (err) {
|
||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getArticles" },
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -169,7 +199,11 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
|
|
||||||
async createArticle(input: Article): Promise<Article> {
|
async createArticle(input: Article): Promise<Article> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } },
|
{
|
||||||
|
name: "articles.createArticle",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { slug: input.slug },
|
||||||
|
},
|
||||||
async (span) => {
|
async (span) => {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
@@ -190,7 +224,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "createArticle" },
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -233,7 +270,10 @@ export class ArticlesRepository implements IArticlesRepository {
|
|||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "updateArticle" },
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user