Skip to content
Open
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

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,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];
Expand Down
14 changes: 9 additions & 5 deletions apps/portal/src/routes/user-invitation/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,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(),
Expand All @@ -205,16 +205,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);
Expand Down Expand Up @@ -260,5 +257,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;
88 changes: 88 additions & 0 deletions apps/portal/src/routes/user-invitation/page.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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<string, string>) => {
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<string, string>) =>
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();
});
});
116 changes: 116 additions & 0 deletions apps/portal/test/unit/routes/chat.reactions.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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<typeof vi.fn>;

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: '👍' });
});
});
Loading