From 6db720eaf0093197e84b9869f83ea3665ad9834f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 00:54:54 +0100 Subject: [PATCH 01/11] test(cohort): expect organisations from v7.0.0 customers with comma-separated kinds --- apps/cohort/src/lib/organisations.test.ts | 5 +++-- packages/shared/src/lib/server/obp/obpApiVersion.test.ts | 7 +------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/cohort/src/lib/organisations.test.ts b/apps/cohort/src/lib/organisations.test.ts index f9eac938..c17d4708 100644 --- a/apps/cohort/src/lib/organisations.test.ts +++ b/apps/cohort/src/lib/organisations.test.ts @@ -36,7 +36,8 @@ 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 kind staff create, but not startups, as one parameter with comma-separated values', () => { + // 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/packages/shared/src/lib/server/obp/obpApiVersion.test.ts b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts index b368a807..e43c7fa8 100644 --- a/packages/shared/src/lib/server/obp/obpApiVersion.test.ts +++ b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts @@ -17,12 +17,7 @@ const OLD_VERSION = /obp\/(v(?:[1-4])\.\d+\.\d+|v5\.0\.0)(?![\d.])/g; 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' -}; +const NO_V510_EQUIVALENT: Record = {}; function sourceFiles(dir: string): string[] { const out: string[] = []; From 1318ec6a01a81e39af8b794c7384e81f6ee77dce Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 00:55:27 +0100 Subject: [PATCH 02/11] refactor(cohort): list organisations from v7.0.0 customers filtered by attribute --- apps/cohort/scripts/seed-demo-data.mjs | 2 +- apps/cohort/src/lib/organisations.ts | 7 +++++-- .../routes/(protected)/staff/organisations/+page.server.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/cohort/scripts/seed-demo-data.mjs b/apps/cohort/scripts/seed-demo-data.mjs index cf3c0f05..b4ddfa6f 100644 --- a/apps/cohort/scripts/seed-demo-data.mjs +++ b/apps/cohort/scripts/seed-demo-data.mjs @@ -340,7 +340,7 @@ 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) +const organisations = await obp('GET', `/obp/v7.0.0/banks/${space}/customers?organisation_kind=${KINDS.join(',')}`, staff.token) .then((r) => r?.customers ?? []) .catch(() => []); for (const o of data.organisations ?? []) { diff --git a/apps/cohort/src/lib/organisations.ts b/apps/cohort/src/lib/organisations.ts index 0f120557..d432c60b 100644 --- a/apps/cohort/src/lib/organisations.ts +++ b/apps/cohort/src/lib/organisations.ts @@ -50,5 +50,8 @@ 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('&'); +/** + * 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/routes/(protected)/staff/organisations/+page.server.ts b/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts index 4ad8fde4..bb7d7055 100644 --- a/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts +++ b/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts @@ -75,7 +75,7 @@ export const load: PageServerLoad = async ({ locals }) => { lacking.read ? Promise.resolve([]) : obpRequests - .get(`/obp/v4.0.0/banks/${space()}/customers?${organisationsQuery()}`, token) + .get(`/obp/v7.0.0/banks/${space()}/customers?${organisationsQuery()}`, token) .then((r) => (r?.customers ?? []) as Parameters[0][]) .catch(() => []), listRecords(PARTICIPANT, '', token).catch(() => []) From bd0603b1f5614e01940eb015c82f637776ac2ea2 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:22:56 +0100 Subject: [PATCH 03/11] test(cohort): expect only created kinds to be listed as organisations --- apps/cohort/src/lib/organisations.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/cohort/src/lib/organisations.test.ts b/apps/cohort/src/lib/organisations.test.ts index c17d4708..b774b8a4 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,6 +36,17 @@ describe('organisations as Customers', () => { expect(organisationOfCustomer({ customer_id: 'c-2', legal_name: 'X' }).kind).toBe('partner'); }); + it('keeps only Customers whose organisation_kind is a kind staff create', () => { + // An OBP-API without attribute filtering returns every Customer, without attributes. + const kept = organisationsFromCustomers([ + { customer_id: 'c-1', legal_name: 'Uni', customer_attributes: [{ name: 'organisation_kind', value: 'university' }] }, + { customer_id: 'c-2', legal_name: 'Applicant' }, + { customer_id: 'c-3', legal_name: 'Startup Co', customer_attributes: [{ name: 'organisation_kind', value: 'startup' }] }, + { customer_id: 'c-4', legal_name: 'Other', customer_attributes: [{ name: 'organisation_kind', value: 'unknown' }] } + ]); + expect(kept.map((o) => o.id)).toEqual(['c-1']); + }); + it('lists every kind staff create, but not startups, as one parameter with comma-separated values', () => { // 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'); From 2410ed32c12af9f2f9a92c569a199b652fa803b9 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:22:56 +0100 Subject: [PATCH 04/11] fix(cohort): list only Customers of a created kind as organisations --- apps/cohort/src/lib/organisations.ts | 13 +++++++++++++ .../(protected)/staff/organisations/+page.server.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/cohort/src/lib/organisations.ts b/apps/cohort/src/lib/organisations.ts index d432c60b..ff40a42a 100644 --- a/apps/cohort/src/lib/organisations.ts +++ b/apps/cohort/src/lib/organisations.ts @@ -50,6 +50,19 @@ export function organisationOfCustomer(c: { customer_id?: unknown; legal_name?: }; } +type CustomerWithAttributes = Parameters[0]; + +/** + * The organisations among Customers: only those whose organisation_kind attribute is a kind staff create. + * The query asks OBP-API for these already; an OBP-API that does not filter by attribute returns every + * Customer, without attributes, and none of them is then taken for an organisation. + */ +export function organisationsFromCustomers(customers: CustomerWithAttributes[]) { + const isCreatable = (c: CustomerWithAttributes) => + (CREATABLE_KINDS as readonly string[]).includes(String((c.customer_attributes ?? []).find((a) => a.name === ORGANISATION_ATTRIBUTES.kind)?.value ?? '')); + return customers.filter(isCreatable).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. 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 bb7d7055..a826c1ad 100644 --- a/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts +++ b/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts @@ -16,7 +16,7 @@ * 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, organisationOfCustomer, 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'; @@ -81,7 +81,7 @@ export const load: PageServerLoad = async ({ locals }) => { 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)}`, From a1a2fa28cff0bbca20106604bf9c2357a8d4d5ff Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:17:08 +0100 Subject: [PATCH 05/11] test(cohort): expect every page of organisation Customers to be read --- .../lib/server/organisationCustomers.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/cohort/src/lib/server/organisationCustomers.test.ts 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..0e27fb9e --- /dev/null +++ b/apps/cohort/src/lib/server/organisationCustomers.test.ts @@ -0,0 +1,44 @@ +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); + +const page = (n: number, from = 0) => ({ + customers: Array.from({ length: n }, (_, i) => ({ customer_id: `c-${from + i}` })) +}); + +describe('organisationCustomers', () => { + beforeEach(() => get.mockReset()); + + it('reads every page of the attribute-filtered Customers, not only the first 50', async () => { + get.mockResolvedValueOnce(page(200)).mockResolvedValueOnce(page(3, 200)); + const customers = await organisationCustomers( + 'cohort', + 'organisation_kind=bank_fi,university', + 'token-1' + ); + expect(customers).toHaveLength(203); + expect(get).toHaveBeenNthCalledWith( + 1, + '/obp/v7.0.0/banks/cohort/customers?organisation_kind=bank_fi,university&limit=200&offset=0', + 'token-1' + ); + expect(get).toHaveBeenNthCalledWith( + 2, + '/obp/v7.0.0/banks/cohort/customers?organisation_kind=bank_fi,university&limit=200&offset=200', + 'token-1' + ); + expect(get).toHaveBeenCalledTimes(2); + }); + + it('stops after a page that is not full, and when a page is empty', async () => { + get.mockResolvedValueOnce(page(0)); + expect(await organisationCustomers('cohort', 'organisation_kind=partner', 'token-1')).toEqual( + [] + ); + expect(get).toHaveBeenCalledTimes(1); + }); +}); From 0dd14bdd4712fcbb38136821dfd3d956fc5f2996 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:17:56 +0100 Subject: [PATCH 06/11] fix(cohort): read every page of organisation Customers from OBP-API v7.0.0 --- apps/cohort/scripts/seed-demo-data.mjs | 2 +- .../src/lib/server/organisationCustomers.ts | 47 +++++++++++++++++++ .../staff/organisations/+page.server.ts | 8 ++-- 3 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 apps/cohort/src/lib/server/organisationCustomers.ts diff --git a/apps/cohort/scripts/seed-demo-data.mjs b/apps/cohort/scripts/seed-demo-data.mjs index b4ddfa6f..c9f9dc79 100644 --- a/apps/cohort/scripts/seed-demo-data.mjs +++ b/apps/cohort/scripts/seed-demo-data.mjs @@ -340,7 +340,7 @@ 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/v7.0.0/banks/${space}/customers?organisation_kind=${KINDS.join(',')}`, staff.token) +const organisations = await obp('GET', `/obp/v7.0.0/banks/${space}/customers?organisation_kind=${KINDS.join(',')}&limit=1000`, staff.token) .then((r) => r?.customers ?? []) .catch(() => []); for (const o of data.organisations ?? []) { diff --git a/apps/cohort/src/lib/server/organisationCustomers.ts b/apps/cohort/src/lib/server/organisationCustomers.ts new file mode 100644 index 00000000..a77c52c3 --- /dev/null +++ b/apps/cohort/src/lib/server/organisationCustomers.ts @@ -0,0 +1,47 @@ +/* + * 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 not full. + */ +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 < PAGE_SIZE) 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 a826c1ad..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, organisationsFromCustomers, 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,10 +75,7 @@ export const load: PageServerLoad = async ({ locals }) => { const [customers, participants] = await Promise.all([ lacking.read ? Promise.resolve([]) - : obpRequests - .get(`/obp/v7.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 }[] = [ From 60b7dc391f7220964bda2c8998169b1ec1ca5a2e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:20:00 +0100 Subject: [PATCH 07/11] test(cohort): expect paging to continue past a short page --- .../lib/server/organisationCustomers.test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/cohort/src/lib/server/organisationCustomers.test.ts b/apps/cohort/src/lib/server/organisationCustomers.test.ts index 0e27fb9e..7cfa3627 100644 --- a/apps/cohort/src/lib/server/organisationCustomers.test.ts +++ b/apps/cohort/src/lib/server/organisationCustomers.test.ts @@ -14,7 +14,10 @@ describe('organisationCustomers', () => { beforeEach(() => get.mockReset()); it('reads every page of the attribute-filtered Customers, not only the first 50', async () => { - get.mockResolvedValueOnce(page(200)).mockResolvedValueOnce(page(3, 200)); + get + .mockResolvedValueOnce(page(200)) + .mockResolvedValueOnce(page(3, 200)) + .mockResolvedValueOnce(page(0)); const customers = await organisationCustomers( 'cohort', 'organisation_kind=bank_fi,university', @@ -31,10 +34,21 @@ describe('organisationCustomers', () => { '/obp/v7.0.0/banks/cohort/customers?organisation_kind=bank_fi,university&limit=200&offset=200', 'token-1' ); - expect(get).toHaveBeenCalledTimes(2); }); - it('stops after a page that is not full, and when a page is empty', async () => { + it('does not stop at a page OBP-API returned short, only at an empty one', async () => { + // A page is short 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); + expect(get).toHaveBeenCalledTimes(3); + }); + + it('stops at an empty page', async () => { get.mockResolvedValueOnce(page(0)); expect(await organisationCustomers('cohort', 'organisation_kind=partner', 'token-1')).toEqual( [] From 8369db87e6c1a37a63ddd9d7ce738236052a68cb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 02:20:29 +0100 Subject: [PATCH 08/11] fix(cohort): read organisation Customers until an empty page --- apps/cohort/scripts/seed-demo-data.mjs | 12 +++++++++--- apps/cohort/src/lib/server/organisationCustomers.ts | 5 +++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/cohort/scripts/seed-demo-data.mjs b/apps/cohort/scripts/seed-demo-data.mjs index c9f9dc79..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/v7.0.0/banks/${space}/customers?organisation_kind=${KINDS.join(',')}&limit=1000`, 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/server/organisationCustomers.ts b/apps/cohort/src/lib/server/organisationCustomers.ts index a77c52c3..3a93a2f5 100644 --- a/apps/cohort/src/lib/server/organisationCustomers.ts +++ b/apps/cohort/src/lib/server/organisationCustomers.ts @@ -26,7 +26,8 @@ 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 not full. + * 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, @@ -41,7 +42,7 @@ export async function organisationCustomers( ); const customers: CustomerWithAttributes[] = response?.customers ?? []; all.push(...customers); - if (customers.length < PAGE_SIZE) break; + if (customers.length === 0) break; } return all; } From 77407986b3fb6f2e46b68d21d8d6a308e4114b6d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 12:05:04 +0100 Subject: [PATCH 09/11] test(cohort): expect every Customer OBP-API returns to be listed as an organisation --- apps/cohort/src/lib/organisations.test.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/cohort/src/lib/organisations.test.ts b/apps/cohort/src/lib/organisations.test.ts index b774b8a4..0089c024 100644 --- a/apps/cohort/src/lib/organisations.test.ts +++ b/apps/cohort/src/lib/organisations.test.ts @@ -36,15 +36,21 @@ describe('organisations as Customers', () => { expect(organisationOfCustomer({ customer_id: 'c-2', legal_name: 'X' }).kind).toBe('partner'); }); - it('keeps only Customers whose organisation_kind is a kind staff create', () => { - // An OBP-API without attribute filtering returns every Customer, without attributes. - const kept = organisationsFromCustomers([ + it('lists every Customer OBP-API returns for the query, because OBP-API filters by organisation_kind', () => { + const listed = organisationsFromCustomers([ { customer_id: 'c-1', legal_name: 'Uni', customer_attributes: [{ name: 'organisation_kind', value: 'university' }] }, - { customer_id: 'c-2', legal_name: 'Applicant' }, - { customer_id: 'c-3', legal_name: 'Startup Co', customer_attributes: [{ name: 'organisation_kind', value: 'startup' }] }, - { customer_id: 'c-4', legal_name: 'Other', customer_attributes: [{ name: 'organisation_kind', value: 'unknown' }] } + { customer_id: 'c-2', legal_name: 'Bank', customer_attributes: [{ name: 'organisation_kind', value: 'bank_fi' }] } ]); - expect(kept.map((o) => o.id)).toEqual(['c-1']); + 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', () => { + // The query asks OBP-API for the kinds staff create; Cohort shows what OBP-API returns. + 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', () => { From ba12c551349bfbe19b62206dbca1cb2432498987 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 12:05:26 +0100 Subject: [PATCH 10/11] refactor(cohort): show the organisations OBP-API returns without filtering them again OBP-API v7.0.0 already filters the Customers by organisation_kind, so the same check no longer lives in Cohort as well. --- apps/cohort/src/lib/organisations.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/cohort/src/lib/organisations.ts b/apps/cohort/src/lib/organisations.ts index ff40a42a..0d3c1ace 100644 --- a/apps/cohort/src/lib/organisations.ts +++ b/apps/cohort/src/lib/organisations.ts @@ -53,14 +53,11 @@ export function organisationOfCustomer(c: { customer_id?: unknown; legal_name?: type CustomerWithAttributes = Parameters[0]; /** - * The organisations among Customers: only those whose organisation_kind attribute is a kind staff create. - * The query asks OBP-API for these already; an OBP-API that does not filter by attribute returns every - * Customer, without attributes, and none of them is then taken for an organisation. + * 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[]) { - const isCreatable = (c: CustomerWithAttributes) => - (CREATABLE_KINDS as readonly string[]).includes(String((c.customer_attributes ?? []).find((a) => a.name === ORGANISATION_ATTRIBUTES.kind)?.value ?? '')); - return customers.filter(isCreatable).map(organisationOfCustomer); + return customers.map(organisationOfCustomer); } /** From 2ceca400ef3066a5f2230e5d389ea358b1b9fb77 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 10 Nov 2026 12:12:43 +0100 Subject: [PATCH 11/11] test(cohort): say what each organisations and version test checks --- apps/cohort/src/lib/organisations.test.ts | 5 +++- .../lib/server/organisationCustomers.test.ts | 28 ++++++++++++++++++- .../src/lib/server/obp/obpApiVersion.test.ts | 5 +++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/cohort/src/lib/organisations.test.ts b/apps/cohort/src/lib/organisations.test.ts index 0089c024..305915d8 100644 --- a/apps/cohort/src/lib/organisations.test.ts +++ b/apps/cohort/src/lib/organisations.test.ts @@ -37,6 +37,7 @@ describe('organisations as Customers', () => { }); 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' }] } @@ -48,12 +49,14 @@ describe('organisations as Customers', () => { }); it('does not filter the Customers a second time', () => { - // The query asks OBP-API for the kinds staff create; Cohort shows what OBP-API returns. + // 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/server/organisationCustomers.test.ts b/apps/cohort/src/lib/server/organisationCustomers.test.ts index 7cfa3627..10789d38 100644 --- a/apps/cohort/src/lib/server/organisationCustomers.test.ts +++ b/apps/cohort/src/lib/server/organisationCustomers.test.ts @@ -1,3 +1,20 @@ +/* + * 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() } })); @@ -6,14 +23,17 @@ 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)) @@ -23,12 +43,15 @@ describe('organisationCustomers', () => { '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', @@ -37,7 +60,8 @@ describe('organisationCustomers', () => { }); it('does not stop at a page OBP-API returned short, only at an empty one', async () => { - // A page is short when a matching attribute belongs to a Customer that no longer exists. + // 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)) @@ -45,10 +69,12 @@ describe('organisationCustomers', () => { 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( [] diff --git a/packages/shared/src/lib/server/obp/obpApiVersion.test.ts b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts index e43c7fa8..9125eb58 100644 --- a/packages/shared/src/lib/server/obp/obpApiVersion.test.ts +++ b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts @@ -16,7 +16,10 @@ 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 */ +/** + * 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[] {