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
12 changes: 9 additions & 3 deletions apps/cohort/scripts/seed-demo-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 24 additions & 3 deletions apps/cohort/src/lib/organisations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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', () => {
Expand All @@ -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');
});
});
17 changes: 15 additions & 2 deletions apps/cohort/src/lib/organisations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof organisationOfCustomer>[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(',')}`;
84 changes: 84 additions & 0 deletions apps/cohort/src/lib/server/organisationCustomers.test.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
48 changes: 48 additions & 0 deletions apps/cohort/src/lib/server/organisationCustomers.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
import type { organisationOfCustomer } from '$lib/organisations';
import { obpRequests } from '$lib/server/obp';

type CustomerWithAttributes = Parameters<typeof organisationOfCustomer>[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<CustomerWithAttributes[]> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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';
Expand Down Expand Up @@ -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<typeof organisationOfCustomer>[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)}`,
Expand Down
12 changes: 5 additions & 7 deletions packages/shared/src/lib/server/obp/obpApiVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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<string, string> = {};

function sourceFiles(dir: string): string[] {
const out: string[] = [];
Expand Down
Loading