Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/lib/rest-client/rest-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,80 @@ describe('rest-client', () => {
}, 10000);
});

describe('searchPackages full-catalog sorts (date-asc, date-desc, pullCount-asc)', () => {
// 150 packages: more than the old fast-path buffer (limit 30 + 82 hidden = 112), with the
// true oldest / lowest-pull items deliberately placed at the tail of the API's own
// (unsorted) order, so a sort over only the first ~112 returned would get them wrong.
const TOTAL = 150;
const permute = (i: number, pivot: number) => ((((i - pivot) * 37) % TOTAL) + TOTAL) % TOTAL;
const day = (n: number) => new Date(Date.UTC(2020, 0, 1 + n)).toISOString();
const catalog = Array.from({ length: TOTAL }, (_, i) => ({
name: `pkg-${String(i).padStart(3, '0')}`,
version: '1.0.0',
URL: `https://example.com/pkg-${i}`,
summary: `Summary ${i}`,
keywords: ['Area/Integration', 'Vendor/Test', 'Type/Connector'],
icon: 'https://example.com/icon.png',
createdDate: day(permute(i, 140)), // oldest (day 0) is pkg-140
pullCount: [130, 145].includes(i) ? 0 : permute(i, 130) * 5 + 1, // lowest are pkg-130/145
}));

// Serves the catalog in its fixed order by offset/limit, like Central ignoring the sort
// for our purposes; any other URL (e.g. ranking data) gets an empty payload.
beforeEach(() => {
mockFetch.mockImplementation(async (url: string) => {
if (!url.includes('offset=')) {
return { ok: true, json: () => Promise.resolve({ packages: {} }) };
}
const params = new URL(url, 'https://example.com').searchParams;
const offset = Number(params.get('offset'));
const limit = Number(params.get('limit'));
return {
ok: true,
json: () =>
Promise.resolve({
packages: catalog.slice(offset, offset + limit),
count: TOTAL,
offset,
limit,
}),
};
});
});

it('should put the true oldest package first for date-asc, not the oldest of a buffer', async () => {
const result = await searchPackages({ offset: 0, limit: 30, sort: 'date-asc' });
expect(result.count).toBe(TOTAL);
expect(result.packages[0].name).toBe('pkg-140');
});

it('should put the true newest package first for date-desc', async () => {
const newest = catalog.reduce((a, b) => (b.createdDate > a.createdDate ? b : a));
const result = await searchPackages({ offset: 0, limit: 30, sort: 'date-desc' });
expect(result.packages[0].name).toBe(newest.name);
});

it('should put a zero-pull package first for pullCount-asc, from across the whole catalog', async () => {
const result = await searchPackages({ offset: 0, limit: 30, sort: 'pullCount-asc' });
expect(result.packages[0].totalPullCount).toBe(0);
expect(['pkg-130', 'pkg-145']).toContain(result.packages[0].name);
expect(['pkg-130', 'pkg-145']).toContain(result.packages[1].name);
});

it('should paginate the full sorted array with no duplicates or gaps', async () => {
const seen: string[] = [];
for (let offset = 0; offset < TOTAL; offset += 30) {
const page = await searchPackages({ offset, limit: 30, sort: 'date-asc' });
expect(page.packages).toHaveLength(30);
seen.push(...page.packages.map((p) => p.name));
}
expect(new Set(seen).size).toBe(TOTAL);
expect([...seen].sort()).toEqual(catalog.map((p) => p.name));
const dates = seen.map((name) => catalog.find((p) => p.name === name)!.createdDate);
expect(dates).toEqual([...dates].sort());
});
});

describe('fetchFiltersProgressively', () => {
it('should return cached filters if available and not expired', async () => {
const freshCache = {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/rest-client/rest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export interface SearchParams {
limit: number;
sort: SortOption;
orgName?: string;
/**
* Internal: keep date-desc/date-asc/pullCount-asc on the fast path (one page-sized
* request) instead of the full catalog fetch. For the filter-building loops, which
* read raw packages page by page and don't care about a globally correct order.
* Has no effect on queries, Area/Vendor/Type filters or the other sorts.
*/
skipFullFetch?: boolean;
}

/**
Expand Down Expand Up @@ -673,11 +680,17 @@ export async function searchPackages(params: SearchParams): Promise<SearchRespon
// covering the whole catalog -- correctly ranking it requires comparing every
// matching package against each other, not just whatever one page Central's
// own API-side sort would have returned first.
// date-desc/date-asc/pullCount-asc need it for the same reason: a small per-page
// buffer can't produce a globally correct order.
const needsFullFetch =
!!params.query ||
params.sort === 'name-asc' ||
params.sort === 'name-desc' ||
params.sort === 'pullCount-desc' ||
(!params.skipFullFetch &&
(params.sort === 'date-desc' ||
params.sort === 'date-asc' ||
params.sort === 'pullCount-asc')) ||
hasKeywordFilters;

if (needsFullFetch) {
Expand Down Expand Up @@ -815,6 +828,7 @@ export async function fetchAllPackagesForFilters(orgName?: string): Promise<Filt
limit: batchSize,
sort: 'date-desc',
orgName,
skipFullFetch: true,
});

allPackages = [...allPackages, ...response.packages];
Expand Down Expand Up @@ -914,6 +928,7 @@ export async function fetchFiltersProgressively(
limit: 100,
sort: 'date-desc',
orgName,
skipFullFetch: true,
});

const initialFilters = extractFilterOptions(firstBatch.packages);
Expand Down
Loading