diff --git a/apps/cohort/scripts/seed-demo-data.mjs b/apps/cohort/scripts/seed-demo-data.mjs index cf3c0f05..00ead8cd 100644 --- a/apps/cohort/scripts/seed-demo-data.mjs +++ b/apps/cohort/scripts/seed-demo-data.mjs @@ -340,9 +340,15 @@ for (const c of data.calls ?? []) { // Organisations: CORPORATE Customers of the space, with their kind, Arabic name and website as Customer Attributes. const KINDS = ['bank_fi', 'university', 'regulator', 'partner']; -const organisations = await obp('GET', `/obp/v4.0.0/banks/${space}/customers?${KINDS.map((k) => `organisation_kind=${k}`).join('&')}`, staff.token) - .then((r) => r?.customers ?? []) - .catch(() => []); +// Every page: OBP-API pages the list, and a page can be short before the end. +const organisations = []; +for (let offset = 0; offset < 10000; offset += 200) { + const page = await obp('GET', `/obp/v7.0.0/banks/${space}/customers?organisation_kind=${KINDS.join(',')}&limit=200&offset=${offset}`, staff.token) + .then((r) => r?.customers ?? []) + .catch(() => []); + if (page.length === 0) break; + organisations.push(...page); +} for (const o of data.organisations ?? []) { await step(`organisation ${o.name}`, async () => { if (organisations.some((c) => c.legal_name === o.name)) return count('organisations', false); diff --git a/apps/cohort/src/lib/organisations.test.ts b/apps/cohort/src/lib/organisations.test.ts index f9eac938..305915d8 100644 --- a/apps/cohort/src/lib/organisations.test.ts +++ b/apps/cohort/src/lib/organisations.test.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ import { describe, expect, it } from 'vitest'; -import { organisationOfCustomer, organisationsQuery } from './organisations'; +import { organisationOfCustomer, organisationsFromCustomers, organisationsQuery } from './organisations'; describe('organisations as Customers', () => { it('reads an organisation from a Customer and its attributes', () => { @@ -36,7 +36,28 @@ describe('organisations as Customers', () => { expect(organisationOfCustomer({ customer_id: 'c-2', legal_name: 'X' }).kind).toBe('partner'); }); - it('lists every kind staff create, but not startups', () => { - expect(organisationsQuery()).toBe('organisation_kind=bank_fi&organisation_kind=university&organisation_kind=regulator&organisation_kind=partner'); + it('lists every Customer OBP-API returns for the query, because OBP-API filters by organisation_kind', () => { + // Tests that each Customer becomes one organisation, in OBP-API's order, with the kind from its attribute. + const listed = organisationsFromCustomers([ + { customer_id: 'c-1', legal_name: 'Uni', customer_attributes: [{ name: 'organisation_kind', value: 'university' }] }, + { customer_id: 'c-2', legal_name: 'Bank', customer_attributes: [{ name: 'organisation_kind', value: 'bank_fi' }] } + ]); + expect(listed.map((o) => [o.id, o.kind])).toEqual([ + ['c-1', 'university'], + ['c-2', 'bank_fi'] + ]); + }); + + it('does not filter the Customers a second time', () => { + // Tests that Cohort trusts OBP-API's answer: the query asks OBP-API for the kinds staff create, and a + // Customer without organisation_kind is still listed, not dropped by a second filter in Cohort. + const listed = organisationsFromCustomers([{ customer_id: 'c-3', legal_name: 'Other' }]); + expect(listed.map((o) => o.id)).toEqual(['c-3']); + }); + + it('lists every kind staff create, but not startups, as one parameter with comma-separated values', () => { + // Tests the query Cohort sends: one organisation_kind parameter with the four kinds, without startup. + // OBP-API refuses a query parameter name given twice (OBP-09016); v7.0.0 accepts several values separated by commas. + expect(organisationsQuery()).toBe('organisation_kind=bank_fi,university,regulator,partner'); }); }); diff --git a/apps/cohort/src/lib/organisations.ts b/apps/cohort/src/lib/organisations.ts index 0f120557..0d3c1ace 100644 --- a/apps/cohort/src/lib/organisations.ts +++ b/apps/cohort/src/lib/organisations.ts @@ -50,5 +50,18 @@ export function organisationOfCustomer(c: { customer_id?: unknown; legal_name?: }; } -/** The query listing the organisations staff created: Customers whose organisation_kind is any creatable kind. */ -export const organisationsQuery = () => CREATABLE_KINDS.map((k) => `${ORGANISATION_ATTRIBUTES.kind}=${k}`).join('&'); +type CustomerWithAttributes = Parameters[0]; + +/** + * The organisations OBP-API returns for organisationsQuery(). OBP-API filters the Customers by their + * organisation_kind attribute, so Cohort shows each of them as it is. + */ +export function organisationsFromCustomers(customers: CustomerWithAttributes[]) { + return customers.map(organisationOfCustomer); +} + +/** + * The query listing the organisations staff created: Customers whose organisation_kind is any creatable kind. + * One parameter with comma-separated values, as GET /obp/v7.0.0/banks/BANK_ID/customers takes them. + */ +export const organisationsQuery = () => `${ORGANISATION_ATTRIBUTES.kind}=${CREATABLE_KINDS.map(encodeURIComponent).join(',')}`; diff --git a/apps/cohort/src/lib/server/organisationCustomers.test.ts b/apps/cohort/src/lib/server/organisationCustomers.test.ts new file mode 100644 index 00000000..10789d38 --- /dev/null +++ b/apps/cohort/src/lib/server/organisationCustomers.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2025-2026 TESOBE GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./obp', () => ({ obpRequests: { get: vi.fn() } })); + +const { obpRequests } = await import('./obp'); +const { organisationCustomers } = await import('./organisationCustomers'); +const get = vi.mocked(obpRequests.get); + +/** A page of n Customers as OBP-API returns it, with ids starting at `from`. */ +const page = (n: number, from = 0) => ({ + customers: Array.from({ length: n }, (_, i) => ({ customer_id: `c-${from + i}` })) +}); + +// organisationCustomers reads the organisations' Customers from OBP-API v7.0.0, page by page. +describe('organisationCustomers', () => { + beforeEach(() => get.mockReset()); + + it('reads every page of the attribute-filtered Customers, not only the first 50', async () => { + // Tests that Cohort asks for 200 at a time and keeps reading: OBP-API returns 50 without a limit. + get + .mockResolvedValueOnce(page(200)) + .mockResolvedValueOnce(page(3, 200)) + .mockResolvedValueOnce(page(0)); + const customers = await organisationCustomers( + 'cohort', + 'organisation_kind=bank_fi,university', + 'token-1' + ); + // All three pages are read: 200 + 3 Customers. + expect(customers).toHaveLength(203); + // The first request: the attribute filter, limit=200 and offset=0, on v7.0.0. + expect(get).toHaveBeenNthCalledWith( + 1, + '/obp/v7.0.0/banks/cohort/customers?organisation_kind=bank_fi,university&limit=200&offset=0', + 'token-1' + ); + // The next request moves on by the page size: offset=200. + expect(get).toHaveBeenNthCalledWith( + 2, + '/obp/v7.0.0/banks/cohort/customers?organisation_kind=bank_fi,university&limit=200&offset=200', + 'token-1' + ); + }); + + it('does not stop at a page OBP-API returned short, only at an empty one', async () => { + // Tests the end of the list: only an empty page ends it. A page can be shorter than the limit before + // the end, when a matching attribute belongs to a Customer that no longer exists. + get + .mockResolvedValueOnce(page(199)) + .mockResolvedValueOnce(page(50, 200)) + .mockResolvedValueOnce(page(0)); + expect( + await organisationCustomers('cohort', 'organisation_kind=partner', 'token-1') + ).toHaveLength(249); + // 199 is short, yet the next page is read; the empty third page ends the reading. + expect(get).toHaveBeenCalledTimes(3); + }); + + it('stops at an empty page', async () => { + // Tests a space without organisations: one request, an empty list, no further pages. + get.mockResolvedValueOnce(page(0)); + expect(await organisationCustomers('cohort', 'organisation_kind=partner', 'token-1')).toEqual( + [] + ); + expect(get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cohort/src/lib/server/organisationCustomers.ts b/apps/cohort/src/lib/server/organisationCustomers.ts new file mode 100644 index 00000000..3a93a2f5 --- /dev/null +++ b/apps/cohort/src/lib/server/organisationCustomers.ts @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025-2026 TESOBE GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import type { organisationOfCustomer } from '$lib/organisations'; +import { obpRequests } from '$lib/server/obp'; + +type CustomerWithAttributes = Parameters[0]; + +const PAGE_SIZE = 200; +const MAX_PAGES = 50; + +/** + * Every Customer of the space matching the attribute query, from + * GET /obp/v7.0.0/banks/BANK_ID/customers. The endpoint pages its results (50 by default), + * so pages are read until one is empty: a page can be short before the end, when a matching + * attribute belongs to a Customer that no longer exists. + */ +export async function organisationCustomers( + space: string, + attributeQuery: string, + token: string +): Promise { + const all: CustomerWithAttributes[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const response = await obpRequests.get( + `/obp/v7.0.0/banks/${space}/customers?${attributeQuery}&limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`, + token + ); + const customers: CustomerWithAttributes[] = response?.customers ?? []; + all.push(...customers); + if (customers.length === 0) break; + } + return all; +} diff --git a/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts b/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts index 4ad8fde4..87ab67fe 100644 --- a/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts +++ b/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts @@ -16,10 +16,11 @@ * along with this program. If not, see . */ import { fail } from '@sveltejs/kit'; -import { isOrganisationKind, ORGANISATION_ATTRIBUTES, organisationOfCustomer, organisationsQuery, type OrganisationKind } from '$lib/organisations'; +import { isOrganisationKind, ORGANISATION_ATTRIBUTES, organisationsFromCustomers, organisationsQuery, type OrganisationKind } from '$lib/organisations'; import { PARTICIPANT } from '$lib/server/entities/definitions'; import { messageOf } from '$lib/server/errors'; import { obpRequests } from '$lib/server/obp'; +import { organisationCustomers } from '$lib/server/organisationCustomers'; import { idOf, listRecords, userToken } from '$lib/server/records'; import { cohortSpace } from '$lib/server/space'; import { staffAccess } from '$lib/server/staffAccess'; @@ -74,14 +75,11 @@ export const load: PageServerLoad = async ({ locals }) => { const [customers, participants] = await Promise.all([ lacking.read ? Promise.resolve([]) - : obpRequests - .get(`/obp/v4.0.0/banks/${space()}/customers?${organisationsQuery()}`, token) - .then((r) => (r?.customers ?? []) as Parameters[0][]) - .catch(() => []), + : organisationCustomers(space(), organisationsQuery(), token).catch(() => []), listRecords(PARTICIPANT, '', token).catch(() => []) ]); const organisations: { id: string; name: string; name_ar: string; kind: OrganisationKind; customer_id: string; website: string }[] = [ - ...customers.map(organisationOfCustomer), + ...organisationsFromCustomers(customers), // A startup's registered company is an organisation too, once; listed from its participation. ...[...new Map(participants.filter((p) => p.customer_id).map((p) => [String(p.customer_id), p])).values()].map((p) => ({ id: `participant:${idOf(PARTICIPANT, p)}`, diff --git a/packages/shared/src/lib/server/obp/obpApiVersion.test.ts b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts index b368a807..9125eb58 100644 --- a/packages/shared/src/lib/server/obp/obpApiVersion.test.ts +++ b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts @@ -16,13 +16,11 @@ const OLD_VERSION = /obp\/(v(?:[1-4])\.\d+\.\d+|v5\.0\.0)(?![\d.])/g; /** A string literal that is only a version, as in `const API_VERSION = 'v4.0.0'`, used to build paths. */ const OLD_VERSION_LITERAL = /['"`](v(?:[1-4])\.\d+\.\d+|v5\.0\.0)['"`]/g; -/** file (repo-relative) + path fragment -> why it is still below v5.1.0 */ -const NO_V510_EQUIVALENT: Record = { - 'apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts|/obp/v4.0.0/banks/${space()}/customers?': - 'v4.0.0 filters customers by attribute; v5.1.0 serves getCustomersAtOneBank without attribute filtering', - 'apps/cohort/scripts/seed-demo-data.mjs|/obp/v4.0.0/banks/${space}/customers?': - 'v4.0.0 filters customers by attribute; v5.1.0 serves getCustomersAtOneBank without attribute filtering' -}; +/** + * file (repo-relative) + path fragment -> why it is still below v5.1.0. Empty: every OBP-API call in the + * app stack now has a v5.1.0 or newer equivalent, so the test allows no exception. + */ +const NO_V510_EQUIVALENT: Record = {}; function sourceFiles(dir: string): string[] { const out: string[] = [];