diff --git a/apps/api-manager/src/routes/backend/obp/banks/[bank_id]/views/+server.ts b/apps/api-manager/src/routes/backend/obp/banks/[bank_id]/views/+server.ts
deleted file mode 100644
index d3d00fd9..00000000
--- a/apps/api-manager/src/routes/backend/obp/banks/[bank_id]/views/+server.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- * 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 { json } from "@sveltejs/kit";
-import type { RequestHandler } from "./$types";
-import { obp_requests } from "$lib/obp/requests";
-import { obpErrorResponse } from "$lib/obp/errors";
-import { SessionOAuthHelper } from "$lib/oauth/sessionHelper";
-import { createLogger } from '@obp/shared/utils';
-
-const logger = createLogger("CustomViewsAPI");
-
-export const POST: RequestHandler = async ({ locals, params, request }) => {
- const session = locals.session;
-
- if (!session?.data?.user) {
- return json({ message: "Unauthorized", code: 401 }, { status: 401 });
- }
-
- // Get the OAuth session data
- const sessionOAuth = SessionOAuthHelper.getSessionOAuth(session);
- const accessToken = sessionOAuth?.accessToken;
-
- if (!accessToken) {
- logger.warn("No access token available for custom view creation");
- return json({ message: "No API access token available", code: 401 }, { status: 401 });
- }
-
- const { bank_id } = params;
-
- if (!bank_id) {
- return json({ message: "Bank ID is required", code: 400 }, { status: 400 });
- }
-
- try {
- const body = await request.json();
-
- logger.info("=== CREATE CUSTOM VIEW API CALL ===");
- logger.info(`Bank ID: ${bank_id}`);
- logger.info(`View Name: ${body.name}`);
-
- // Build the payload for the OBP API
- const payload: any = {
- name: body.name,
- description: body.description,
- is_public: body.is_public || false,
- metadata_view: body.metadata_view || "_0",
- which_alias_to_use: body.which_alias_to_use || "",
- hide_metadata_if_alias_used: body.hide_metadata_if_alias_used || false,
- allowed_actions: body.allowed_actions || [],
- };
-
- // Add all permission fields that are enabled
- const permissionFields = [
- // Transaction permissions
- "can_see_transaction_this_bank_account",
- "can_see_transaction_other_bank_account",
- "can_see_transaction_metadata",
- "can_see_transaction_label",
- "can_see_transaction_amount",
- "can_see_transaction_type",
- "can_see_transaction_currency",
- "can_see_transaction_start_date",
- "can_see_transaction_finish_date",
- "can_see_transaction_balance",
- // Account permissions
- "can_see_bank_account_owners",
- "can_see_bank_account_type",
- "can_see_bank_account_balance",
- "can_see_bank_account_currency",
- "can_see_bank_account_label",
- "can_see_bank_account_national_identifier",
- "can_see_bank_account_swift_bic",
- "can_see_bank_account_iban",
- "can_see_bank_account_number",
- "can_see_bank_account_bank_name",
- "can_see_bank_account_credit_limit",
- // Counterparty permissions
- "can_see_other_account_national_identifier",
- "can_see_other_account_swift_bic",
- "can_see_other_account_iban",
- "can_see_other_account_bank_name",
- "can_see_other_account_number",
- "can_see_other_account_metadata",
- "can_see_other_account_kind",
- "can_see_public_alias",
- "can_see_private_alias",
- // Other permissions
- "can_see_comments",
- "can_see_narrative",
- "can_see_tags",
- "can_see_images",
- "can_see_more_info",
- "can_see_url",
- "can_see_image_url",
- "can_see_where_tag",
- // Write permissions
- "can_add_comment",
- "can_delete_comment",
- "can_add_tag",
- "can_delete_tag",
- "can_add_image",
- "can_delete_image",
- "can_edit_narrative",
- "can_create_counterparty",
- "can_add_transaction_request_to_own_account",
- "can_add_transaction_request_to_any_account",
- ];
-
- // Add each permission field if it exists in the request body
- permissionFields.forEach((field) => {
- if (body[field] !== undefined) {
- payload[field] = body[field];
- }
- });
-
- // Make the API call to create the custom view
- const endpoint = `/obp/v6.0.0/banks/${bank_id}/views`;
- logger.info(`Creating custom view at: ${endpoint}`);
-
- const response = await obp_requests.post(endpoint, payload, accessToken);
-
- logger.info(`Custom view created successfully: ${response.id}`);
-
- return json(response, { status: 201 });
- } catch (err) {
- logger.error("Error creating custom view:", err);
-
- const { body, status } = obpErrorResponse(err);
- return json(body, { status });
- }
-};
diff --git a/apps/portal/src/routes/(protected)/user/chat/[chatRoomId]/+page.svelte b/apps/portal/src/routes/(protected)/user/chat/[chatRoomId]/+page.svelte
index aed97ce6..11b6fa16 100644
--- a/apps/portal/src/routes/(protected)/user/chat/[chatRoomId]/+page.svelte
+++ b/apps/portal/src/routes/(protected)/user/chat/[chatRoomId]/+page.svelte
@@ -195,11 +195,17 @@
// Optimistic remove
reactions[messageId] = existing.filter(r => !(r.emoji === emoji && r.user_id === data.currentUserId));
try {
- await fetch(`/proxy/obp/v6.0.0/chat-rooms/${data.chatRoom.chat_room_id}/messages/${messageId}/reactions`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ emoji })
+ // The emoji is the last path segment on DELETE, not a body field -- the API
+ // serves .../reactions/{emoji}, and .../reactions is only POST and GET.
+ const res = await fetch(`/proxy/obp/v6.0.0/chat-rooms/${data.chatRoom.chat_room_id}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, {
+ method: 'DELETE'
});
+ if (!res.ok) {
+ // Revert on failure. fetch only rejects on a network error, so without
+ // this check a 4xx left the reaction gone from the UI, still in the
+ // database, and back again on reload -- with nothing logged anywhere.
+ reactions[messageId] = [...(reactions[messageId] || []), myReaction];
+ }
} catch {
// Revert on failure
reactions[messageId] = [...(reactions[messageId] || []), myReaction];
diff --git a/apps/portal/src/routes/user-invitation/+page.server.ts b/apps/portal/src/routes/user-invitation/+page.server.ts
index 594acfe8..c2f6d2ac 100644
--- a/apps/portal/src/routes/user-invitation/+page.server.ts
+++ b/apps/portal/src/routes/user-invitation/+page.server.ts
@@ -204,7 +204,7 @@ export const actions = {
try {
// Option B: Create user account directly via REST API
- // PUT /obp/v4.0.0/banks/{BANK_ID}/user-invitation
+ // POST /obp/v4.0.0/banks/{BANK_ID}/user-invitation
const requestBody: OBPUserInvitationAcceptRequestBody = {
secret_key: parseInt(secretKey),
username: username.trim(),
@@ -222,16 +222,13 @@ export const actions = {
email: email
});
- const response = await obp_requests.put(
+ const response = await obp_requests.post(
`/obp/v4.0.0/banks/${effectiveBankId}/user-invitation`,
requestBody
);
logger.info("User account created successfully via invitation acceptance:", response);
- // Redirect to login page with success message
- throw redirect(303, '/login?invitation_accepted=true');
-
} catch (err) {
if (err instanceof OBPRequestError) {
logger.error("OBP API error during invitation acceptance:", err.message, err.code);
@@ -277,5 +274,12 @@ export const actions = {
success: false
};
}
+
+ // Outside the try on purpose. `redirect()` signals by throwing, so a redirect
+ // raised inside the block above is caught by the same catch, falls through to its
+ // catch-all, and turns a successful account creation into
+ // "Failed to create account: Unknown error". The register action already returns
+ // its redirect after the try for this reason.
+ return redirect(303, '/login?invitation_accepted=true');
}
} satisfies Actions;
\ No newline at end of file
diff --git a/apps/portal/src/routes/user-invitation/page.server.test.ts b/apps/portal/src/routes/user-invitation/page.server.test.ts
new file mode 100644
index 00000000..fa577418
--- /dev/null
+++ b/apps/portal/src/routes/user-invitation/page.server.test.ts
@@ -0,0 +1,105 @@
+/*
+ * 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 { describe, it, expect, vi, beforeEach } from 'vitest';
+import { actions } from './+page.server.js';
+
+// Mock the obp_requests module the action calls through. Both verbs are stubbed so the
+// test can assert which one was used -- that is the whole point of the first case.
+vi.mock('$lib/obp/requests', () => ({
+ obp_requests: {
+ post: vi.fn(),
+ put: vi.fn()
+ }
+}));
+
+vi.mock('$env/dynamic/public', () => ({ env: { PUBLIC_DEFAULT_BANK_ID: 'gh.29.uk' } }));
+vi.mock('$env/dynamic/private', () => ({ env: {} }));
+
+import { obp_requests } from '$lib/obp/requests';
+
+describe('user-invitation accept action', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const createMockRequest = (formData: Record) => {
+ const mockFormData = new FormData();
+ Object.entries(formData).forEach(([key, value]) => {
+ mockFormData.append(key, value);
+ });
+ return { formData: () => Promise.resolve(mockFormData) };
+ };
+
+ const validForm = {
+ secret_key: '12345',
+ username: 'inviteduser',
+ password: 'password123',
+ confirm_password: 'password123',
+ first_name: 'Jane',
+ last_name: 'Doe',
+ email: 'jane@example.com',
+ company: 'Example Ltd',
+ country: 'DE',
+ privacy_policy: 'on',
+ terms_conditions: 'on',
+ personal_data: 'on',
+ bank_id: 'gh.29.uk'
+ };
+
+ const run = (form: Record) =>
+ actions.accept({ request: createMockRequest(form) } as never);
+
+ // The regression this file exists for. OBP-API serves
+ // /obp/v4.0.0/banks/{BANK_ID}/user-invitation as POST only -- there is no PUT on that
+ // path at any version, so the previous `obp_requests.put` could only ever 404 and
+ // accepting an invitation could never complete. Asserting the verb, not just the URL,
+ // is what makes this test able to fail if it regresses.
+ it('accepts an invitation with POST, not PUT', async () => {
+ vi.mocked(obp_requests.post).mockResolvedValue({ user_id: 'user-123' });
+
+ // On success the action throws a SvelteKit redirect to the login page.
+ await expect(run(validForm)).rejects.toMatchObject({
+ status: 303,
+ location: '/login?invitation_accepted=true'
+ });
+
+ expect(obp_requests.put).not.toHaveBeenCalled();
+ expect(obp_requests.post).toHaveBeenCalledWith(
+ '/obp/v4.0.0/banks/gh.29.uk/user-invitation',
+ expect.objectContaining({
+ secret_key: 12345,
+ username: 'inviteduser',
+ email: 'jane@example.com'
+ })
+ );
+ });
+
+ it('rejects a password shorter than the minimum without calling the API', async () => {
+ const result = await run({ ...validForm, password: 'short', confirm_password: 'short' });
+
+ expect(result).toMatchObject({ success: false });
+ expect(obp_requests.post).not.toHaveBeenCalled();
+ });
+
+ it('rejects mismatched password confirmation without calling the API', async () => {
+ const result = await run({ ...validForm, confirm_password: 'different123' });
+
+ expect(result).toMatchObject({ success: false });
+ expect(obp_requests.post).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/portal/test/unit/routes/chat.reactions.svelte.test.ts b/apps/portal/test/unit/routes/chat.reactions.svelte.test.ts
new file mode 100644
index 00000000..bc3fdb7d
--- /dev/null
+++ b/apps/portal/test/unit/routes/chat.reactions.svelte.test.ts
@@ -0,0 +1,133 @@
+/*
+ * 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 { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+
+// The component pulls in $lib/avatar/generate, which reads $env/dynamic/public at module
+// scope. Unmocked, that is undefined under vitest and the import throws before any test
+// runs.
+vi.mock('$env/dynamic/public', () => ({ env: {} }));
+
+import ChatRoomPage from '$lib/../routes/(protected)/user/chat/[chatRoomId]/+page.svelte';
+
+// The component opens an EventSource and a poll interval on mount; jsdom has neither.
+class FakeEventSource {
+ onmessage: ((e: MessageEvent) => void) | null = null;
+ onerror: ((e: Event) => void) | null = null;
+ close() {}
+ addEventListener() {}
+}
+
+const CURRENT_USER = 'user-me';
+const MESSAGE_ID = 'msg-1';
+
+function makeData() {
+ return {
+ chatRoom: { chat_room_id: 'room-1', name: 'Test room' },
+ currentUserId: CURRENT_USER,
+ participants: [{ user_id: CURRENT_USER, username: 'me' }],
+ messages: [
+ {
+ chat_message_id: MESSAGE_ID,
+ message: 'hello',
+ from_user_id: CURRENT_USER,
+ created_at: '2026-01-01T00:00:00Z',
+ // A reaction this user already left, so the badge click removes it.
+ reactions: [{ emoji: '👍', user_ids: [CURRENT_USER] }]
+ }
+ ]
+ } as never;
+}
+
+describe('chat reactions', () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ vi.stubGlobal('EventSource', FakeEventSource);
+ fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ const clickBadge = async () => {
+ const badge = await screen.findByTestId(`reaction-badge-${MESSAGE_ID}-👍`);
+ badge.click();
+ // let the click handler's await settle
+ await new Promise((r) => setTimeout(r, 0));
+ };
+
+ const reactionCalls = () =>
+ fetchMock.mock.calls.filter(([url]) => String(url).includes('/reactions'));
+
+ // The regression this file exists for. OBP-API serves the emoji as the last PATH
+ // segment on DELETE -- `.../reactions/{emoji}` -- and only POST/GET on
+ // `.../reactions`. Sending it in the body meant the request 404'd every time.
+ it('removes a reaction with the emoji in the path, not the body', async () => {
+ render(ChatRoomPage, { data: makeData() });
+ await clickBadge();
+
+ const calls = reactionCalls();
+ expect(calls.length).toBe(1);
+
+ const [url, init] = calls[0];
+ expect(init.method).toBe('DELETE');
+ expect(String(url)).toBe(
+ `/proxy/obp/v6.0.0/chat-rooms/room-1/messages/${MESSAGE_ID}/reactions/${encodeURIComponent('👍')}`
+ );
+ // A body on this DELETE is what the endpoint does not read.
+ expect(init.body).toBeUndefined();
+ });
+
+ // fetch resolves on a 4xx rather than rejecting, so a try/catch alone cannot see the
+ // failure. Before res.ok was checked, a rejected removal left the reaction gone from
+ // the UI, still in the database, and back again on reload -- with nothing logged.
+ it('restores the reaction in the UI when the request fails', async () => {
+ fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
+ render(ChatRoomPage, { data: makeData() });
+
+ await clickBadge();
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(screen.queryByTestId(`reaction-badge-${MESSAGE_ID}-👍`)).not.toBeNull();
+ });
+
+ // The add path was already correct and must stay that way: POST to `.../reactions`
+ // with the emoji in the body is exactly what OBP-API serves.
+ it('adds a reaction with the emoji in the body, unchanged', async () => {
+ const data = makeData();
+ // Nobody has reacted yet, so the same badge click adds rather than removes.
+ (data as never as { messages: Array<{ reactions: unknown[] }> }).messages[0].reactions = [
+ { emoji: '👍', user_ids: ['someone-else'] }
+ ];
+ render(ChatRoomPage, { data });
+ await clickBadge();
+
+ const [url, init] = reactionCalls()[0];
+ expect(init.method).toBe('POST');
+ expect(String(url)).toBe(
+ `/proxy/obp/v6.0.0/chat-rooms/room-1/messages/${MESSAGE_ID}/reactions`
+ );
+ expect(JSON.parse(init.body)).toEqual({ emoji: '👍' });
+ });
+});