Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import {
fetchAllOrganizations,
ORGANIZATIONS_MAX_PAGES,
ORGANIZATIONS_PAGE_SIZE,
} from './OrganizationsPage';

// #3446: the org list rendered only the first page (server default limit 50,
// clamped to 100), so orgs past it were invisible in the list AND in the
// client-side search.

function org(n: number) {
return { id: `org-${n}`, name: `Org ${n}` };
}
/** A server page of `size` orgs starting at `from`. */
function page(from: number, size: number, total: number) {
return { data: Array.from({ length: size }, (_, i) => org(from + i)), pagination: { total } };
}

describe('fetchAllOrganizations (#3446)', () => {
it('walks every page, not just the first', async () => {
// 250 orgs => 100 + 100 + 50
const fetchPage = vi.fn(async (p: number) =>
p === 1 ? page(1, 100, 250) : p === 2 ? page(101, 100, 250) : page(201, 50, 250),
);

const all = await fetchAllOrganizations(fetchPage);

expect(all).toHaveLength(250);
expect(fetchPage).toHaveBeenCalledTimes(3);
// the org that the bug hid — first one past the old 50-row cap
expect((all as Array<{ id: string }>).some((o) => o.id === 'org-51')).toBe(true);
expect((all as Array<{ id: string }>).at(-1)).toEqual(org(250));
});

it('requests the server ceiling so it makes the fewest round-trips', async () => {
const fetchPage = vi.fn(async () => page(1, 10, 10));
await fetchAllOrganizations(fetchPage);
expect(fetchPage).toHaveBeenCalledWith(1, ORGANIZATIONS_PAGE_SIZE);
expect(ORGANIZATIONS_PAGE_SIZE).toBe(100); // the clamp in getPagination
});

it('stops on a short page even when the response carries no pagination block', async () => {
// legacy/unpaginated shape: a bare array
const fetchPage = vi.fn(async () => [org(1), org(2)]);
const all = await fetchAllOrganizations(fetchPage);
expect(all).toHaveLength(2);
expect(fetchPage).toHaveBeenCalledTimes(1);
});

it('accepts the {organizations:[...]} shape', async () => {
const fetchPage = vi.fn(async () => ({ organizations: [org(1)] }));
expect(await fetchAllOrganizations(fetchPage)).toHaveLength(1);
});

it('does not spin forever when the server keeps returning full pages', async () => {
// a `total` that never arrives (or is wrong) must not loop unbounded
const fetchPage = vi.fn(async (p: number) => page((p - 1) * 100 + 1, 100, 10_000_000));
const all = await fetchAllOrganizations(fetchPage);
expect(fetchPage).toHaveBeenCalledTimes(ORGANIZATIONS_MAX_PAGES);
expect(all).toHaveLength(ORGANIZATIONS_MAX_PAGES * 100);
});

it('propagates null so the caller can abort (401 redirect)', async () => {
const fetchPage = vi.fn(async () => null);
expect(await fetchAllOrganizations(fetchPage)).toBeNull();
});

it('lets a thrown fetch error escape rather than silently truncating the list', async () => {
const fetchPage = vi.fn(async (p: number) => {
if (p === 2) throw new Error('boom');
return page(1, 100, 250);
});
await expect(fetchAllOrganizations(fetchPage)).rejects.toThrow('boom');
});
});
74 changes: 59 additions & 15 deletions apps/web/src/components/settings/OrganizationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,54 @@ const statusColors: Record<Organization['status'], string> = {
offboarding: 'border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-400',
};

/**
* Walk every page of GET /orgs/organizations (#3446).
*
* The endpoint paginates: `getPagination` defaults to `limit=50` and CLAMPS to
* 100, so a single request can never return more than 100 organizations no
* matter what limit is requested — "just raise the limit" cannot fix this. This
* page renders the whole list and filters it client-side, so anything past the
* first page was invisible in both the list AND the search box.
*
* `fetchPage` returns the parsed body, or `null` to abort (used for the 401
* redirect); `null` propagates so the caller can bail without rendering.
*/
export const ORGANIZATIONS_PAGE_SIZE = 100; // the server's hard ceiling — fewest round-trips
export const ORGANIZATIONS_MAX_PAGES = 100; // 10k orgs; a stop so a bad `total` cannot spin

export async function fetchAllOrganizations<T = unknown>(
fetchPage: (page: number, limit: number) => Promise<unknown>,
): Promise<T[] | null> {
const all: T[] = [];

for (let page = 1; page <= ORGANIZATIONS_MAX_PAGES; page += 1) {
const data = (await fetchPage(page, ORGANIZATIONS_PAGE_SIZE)) as
| { data?: unknown; organizations?: unknown; pagination?: { total?: unknown } }
| unknown[]
| null;
if (data === null) return null;

const body = data as { data?: unknown; organizations?: unknown; pagination?: { total?: unknown } };
const batch: T[] = Array.isArray(body?.data)
? (body.data as T[])
: Array.isArray(body?.organizations)
? (body.organizations as T[])
: Array.isArray(data)
? (data as T[])
: [];
all.push(...batch);

// Stop on a short page rather than trusting `total` alone: a legacy or
// unpaginated response is a bare array with no pagination block and must
// still terminate.
const total = typeof body?.pagination?.total === 'number' ? body.pagination.total : undefined;
if (batch.length < ORGANIZATIONS_PAGE_SIZE) break;
if (total !== undefined && all.length >= total) break;
}

return all;
}

export default function OrganizationsPage() {
const { t } = useTranslation('settings');
const [organizations, setOrganizations] = useState<Organization[]>([]);
Expand Down Expand Up @@ -84,22 +132,18 @@ export default function OrganizationsPage() {
try {
setLoading(true);
setError(undefined);
const response = await fetchWithAuth('/orgs/organizations');
if (!response.ok) {
if (response.status === 401) {
void navigateTo('/login', { replace: true });
return;
const organizations = await fetchAllOrganizations<Organization>(async (page, limit) => {
const response = await fetchWithAuth(`/orgs/organizations?page=${page}&limit=${limit}`);
if (!response.ok) {
if (response.status === 401) {
void navigateTo('/login', { replace: true });
return null;
}
throw new Error(t('organizationsPage.errors.fetchOrganizations'));
}
throw new Error(t('organizationsPage.errors.fetchOrganizations'));
}
const data = await response.json();
const organizations = Array.isArray(data?.data)
? data.data
: Array.isArray(data?.organizations)
? data.organizations
: Array.isArray(data)
? data
: [];
return response.json();
});
if (organizations === null) return;
setOrganizations(organizations);
} catch (err) {
setError(err instanceof Error ? err.message : t('organizationsPage.errors.generic'));
Expand Down
Loading