Files
renovate/lib/modules/platform/github/api-cache.spec.ts
T
Sergei Zharinov 2fb68b9a20 fix(github): prevent PR cache sync from exhausting all pages (#41462)
* fix(github): prevent PR cache sync from exhausting all pages

* refactor(github): derive cutoff from cached items, simplify reconcile

- When lastModified is missing but items exist (via updateItem()),
  derive cutoff from max(item.updated_at) instead of falling back
  to reconcile's return value. Eliminates the fallback branch.
- reconcile() now called purely for side effects; return value ignored.
- MAX_SYNC_PAGES guard checks needNextPageSync (not needNextPageFetch)
  to avoid spurious warning when cutoff already stopped the loop.
- Add test for mixed pages (Renovate + non-Renovate PRs interleaved).
- Update derived-cutoff test for caches without lastModified.

* fix(github): advance PR cache watermark from unfiltered page

* fix(github): add comment clarifying MAX_SYNC_PAGES is a safety net
2026-02-25 10:16:52 +00:00

251 lines
7.0 KiB
TypeScript

import { DateTime } from 'luxon';
import { ApiCache } from './api-cache.ts';
describe('modules/platform/github/api-cache', () => {
const now = DateTime.fromISO('2000-01-01T00:00:00.000+00:00');
const t1 = now.plus({ years: 1 }).toISO()!;
const t2 = now.plus({ years: 2 }).toISO()!;
const t3 = now.plus({ years: 3 }).toISO()!;
const t4 = now.plus({ years: 4 }).toISO()!;
const t5 = now.plus({ years: 5 }).toISO()!;
it('stores and retrieves items', () => {
const item1 = { number: 1, updated_at: t1 };
const item2 = { number: 2, updated_at: t2 };
const apiCache = new ApiCache({
items: { 1: item1 },
lastModified: t1,
});
expect(apiCache.getItem(1)).toBe(item1);
expect(apiCache.getItem(2)).toBeNull();
apiCache.updateItem(item2);
expect(apiCache.getItem(2)).toBe(item2);
expect(apiCache.getItems()).toEqual([item1, item2]);
});
describe('getItems', () => {
it('maps items', () => {
const item1 = { number: 1, updated_at: t1 };
const item2 = { number: 2, updated_at: t2 };
const item3 = { number: 3, updated_at: t3 };
const apiCache = new ApiCache({
items: {
1: item1,
2: item2,
3: item3,
},
});
const res = apiCache.getItems();
expect(res).toMatchObject([{ number: 1 }, { number: 2 }, { number: 3 }]);
});
it('resets cache on item update', () => {
const item1 = { number: 1, updated_at: t1 };
const item2 = { number: 2, updated_at: t2 };
const item3 = { number: 3, updated_at: t3 };
const apiCache = new ApiCache({
items: {
1: item1,
2: item2,
},
});
const numbers1 = apiCache.getItems();
apiCache.updateItem(item3);
const numbers2 = apiCache.getItems();
expect(numbers1).toMatchObject([{ number: 1 }, { number: 2 }]);
expect(numbers2).toMatchObject([
{ number: 1 },
{ number: 2 },
{ number: 3 },
]);
});
it('resets cache on page reconcile', () => {
const item1 = { number: 1, updated_at: t1 };
const item2 = { number: 2, updated_at: t2 };
const item3 = { number: 3, updated_at: t3 };
const apiCache = new ApiCache({
items: {
1: item1,
2: item2,
},
});
const numbers1 = apiCache.getItems();
apiCache.reconcile([item3]);
const numbers2 = apiCache.getItems();
expect(numbers1).toMatchObject([{ number: 1 }, { number: 2 }]);
expect(numbers2).toMatchObject([
{ number: 1 },
{ number: 2 },
{ number: 3 },
]);
});
});
describe('getLastModified', () => {
it('returns undefined when no lastModified in cache', () => {
const apiCache = new ApiCache({ items: {} });
expect(apiCache.getLastModified()).toBeUndefined();
});
it('returns stored value when present', () => {
const apiCache = new ApiCache({ items: {}, lastModified: t2 });
expect(apiCache.getLastModified()).toBe(t2);
});
it('returns updated value after reconcile', () => {
const apiCache = new ApiCache({ items: {}, lastModified: t1 });
apiCache.reconcile([{ number: 1, updated_at: t3 }]);
expect(apiCache.getLastModified()).toBe(t3);
});
});
describe('updateLastModified', () => {
it('sets lastModified when not present', () => {
const apiCache = new ApiCache({ items: {} });
apiCache.updateLastModified(t2);
expect(apiCache.getLastModified()).toBe(t2);
});
it('advances lastModified to newer timestamp', () => {
const apiCache = new ApiCache({ items: {}, lastModified: t1 });
apiCache.updateLastModified(t3);
expect(apiCache.getLastModified()).toBe(t3);
});
it('does not regress lastModified to older timestamp', () => {
const apiCache = new ApiCache({ items: {}, lastModified: t3 });
apiCache.updateLastModified(t1);
expect(apiCache.getLastModified()).toBe(t3);
});
});
describe('reconcile', () => {
it('returns false for empty page', () => {
const apiCache = new ApiCache({
items: { 1: { number: 1, updated_at: t1 } },
lastModified: t1,
});
expect(apiCache.reconcile([])).toBeFalse();
expect(apiCache.getLastModified()).toBe(t1);
});
it('appends new items', () => {
const apiCache = new ApiCache({ items: {} });
const res1 = apiCache.reconcile([
{ number: 2, updated_at: t2 },
{ number: 1, updated_at: t1 },
]);
expect(res1).toBeTrue();
const res2 = apiCache.reconcile([
{ number: 4, updated_at: t4 },
{ number: 3, updated_at: t3 },
]);
expect(res2).toBeTrue();
expect(apiCache.getItems()).toEqual([
{ number: 1, updated_at: t1 },
{ number: 2, updated_at: t2 },
{ number: 3, updated_at: t3 },
{ number: 4, updated_at: t4 },
]);
});
it('handles updated items', () => {
const apiCache = new ApiCache({
items: {
1: { number: 1, updated_at: t1 },
2: { number: 2, updated_at: t2 },
3: { number: 3, updated_at: t3 },
},
lastModified: t3,
});
const needNextPage = apiCache.reconcile([
{ number: 1, updated_at: t5 },
{ number: 2, updated_at: t4 },
{ number: 3, updated_at: t3 },
]);
expect(apiCache.getItems()).toEqual([
{ number: 1, updated_at: t5 },
{ number: 2, updated_at: t4 },
{ number: 3, updated_at: t3 },
]);
expect(needNextPage).toBeFalse();
});
it('ignores page overlap', () => {
const apiCache = new ApiCache({
items: {},
});
const res1 = apiCache.reconcile([
{ number: 5, updated_at: t5 },
{ number: 4, updated_at: t4 },
{ number: 3, updated_at: t3 },
]);
const res2 = apiCache.reconcile([
{ number: 3, updated_at: t3 },
{ number: 2, updated_at: t2 },
{ number: 1, updated_at: t1 },
]);
expect(apiCache.getItems()).toEqual([
{ number: 1, updated_at: t1 },
{ number: 2, updated_at: t2 },
{ number: 3, updated_at: t3 },
{ number: 4, updated_at: t4 },
{ number: 5, updated_at: t5 },
]);
expect(res1).toBeTrue();
expect(res2).toBeTrue();
});
it('does not require new page if all items are old', () => {
const apiCache = new ApiCache({
items: {
1: { number: 1, updated_at: t1 },
2: { number: 2, updated_at: t2 },
3: { number: 3, updated_at: t3 },
},
lastModified: t3,
});
const needNextPage = apiCache.reconcile([
{ number: 3, updated_at: t3 },
{ number: 2, updated_at: t2 },
{ number: 1, updated_at: t1 },
]);
expect(apiCache.getItems()).toEqual([
{ number: 1, updated_at: t1 },
{ number: 2, updated_at: t2 },
{ number: 3, updated_at: t3 },
]);
expect(needNextPage).toBeFalse();
});
});
});