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
11 changes: 11 additions & 0 deletions docs/test-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ run does not prove for each one; the taxonomy and cross-repository entries live
clerks list, not that the API returns those records or the logged-in staff member's
`verifiedName`. The session is a synthetic unsigned JWT, so a green run also does not
prove login or token verification.
- **The RealUnit support visual spec answers the support endpoints itself.**
`e2e/realunit-support.spec.ts` fulfils `GET /v1/realunit/support/list`, `/counts`, `/activity`,
`/clerks`, `/:id/data` and `/:id/messages` with synthetic `{ userDataId, name }[]` clerks and
`clerkUserDataId` on the issue fixture. A green run proves those fixtures render. It does not
prove that the API returns object clerks, that assignment writes `clerkUserDataId`, or that
login works — auth is a real admin token, feature data is not.
- **The DFX support issue visual spec answers the issue endpoints itself.**
`e2e/support-dashboard-issue.spec.ts` uses a synthetic unsigned Admin JWT and fulfils
`GET /v1/support/issue/clerks`, `/v1/support/issue/:id/data`, `/v1/support/issue/:uid`
(message thread) and staff bootstrap GETs. A green run proves the issue screen renders
that fixture, not that the API returns it or that login works.
- **Full-stack guest assign/refund specs SQL-write `transaction.actionSecretHash`.**
`e2e-stack/specs/transactions.spec.ts` (`seedActionSecret`) updates the hash directly. A green run
does **not** prove that the mail/API path creates, hashes, or delivers the action secret.
Expand Down
7 changes: 6 additions & 1 deletion e2e/realunit-support.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ interface SupportIssueInternalData {
state: string;
name: string;
clerk?: string;
clerkUserDataId?: number;
account: SupportIssueInternalAccountData;
}

Expand Down Expand Up @@ -215,7 +216,10 @@ const COUNTS: Record<string, number> = {
Completed: 9,
};

const CLERKS: string[] = ['Rita Clerk', 'Tom Support'];
const CLERKS: { userDataId: number; name: string }[] = [
{ userDataId: 101, name: 'Rita Clerk' },
{ userDataId: 102, name: 'Tom Support' },
];

// Detail for ISSUE_ID (7001), matching the OPEN_ISSUES[0] header fields.
const ISSUE_DATA: SupportIssueInternalData = {
Expand All @@ -228,6 +232,7 @@ const ISSUE_DATA: SupportIssueInternalData = {
state: 'Pending',
name: 'Alice Muster',
clerk: 'Rita Clerk',
clerkUserDataId: 101,
account: {
id: 8001,
status: 'Active',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
139 changes: 139 additions & 0 deletions e2e/support-dashboard-issue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { test, expect, Page, Route } from '@playwright/test';

/**
* E2E Visual Regression Test: DFX Support issue detail
*
* Auth is a synthetic Admin JWT. Staff GETs and the issue endpoints are mocked, so
* the suite does not need a live API. See docs/test-architecture.md.
*/

const CUSTOMER_AUTHOR = 'Customer';
const ISSUE_ID = 7001;
const ISSUE_UID = 'SI-7001-UID';

function jwt(): string {
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode({
account: 1,
user: 1,
role: 'Admin',
exp: Math.floor(Date.now() / 1000) + 3600,
})}.synthetic`;
}

const CLERKS = [
{ userDataId: 101, name: 'Rita Clerk' },
{ userDataId: 102, name: 'Tom Support' },
];

const ISSUE_DATA = {
id: ISSUE_ID,
created: '2024-01-01T09:00:00.000Z',
uid: ISSUE_UID,
type: 'TransactionIssue',
department: 'Support',
reason: 'FundsNotReceived',
state: 'Pending',
name: 'Alice Muster',
clerk: 'Rita Clerk',
clerkUserDataId: 101,
account: {
id: 8001,
status: 'Active',
verifiedName: 'Alice Muster',
completeName: 'Alice Muster',
accountType: 'Personal',
kycLevel: '50',
depositLimit: 100000,
annualVolume: 25000,
kycHash: 'a1b2c3d4e5',
country: { name: 'Switzerland' },
language: { name: 'English', symbol: 'EN' },
},
};

const MESSAGES = [
{
id: 501,
author: CUSTOMER_AUTHOR,
message: 'Hello, I did not receive my funds for the last transaction.',
created: '2024-01-01T09:05:00.000Z',
},
{
id: 502,
author: 'Rita Clerk',
message: 'Hi Alice, thanks for reaching out.',
created: '2024-01-01T10:30:00.000Z',
},
];

const CLERKS_RE = /\/v1\/support\/issue\/clerks(?:\?|$)/;
const DATA_RE = /\/v1\/support\/issue\/(\d+)\/data(?:\?|$)/;
const THREAD_RE = /\/v1\/support\/issue\/SI-7001-UID(?:\?|$)/;

async function json(route: Route, body: unknown): Promise<void> {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
}

async function installIssueRoutes(page: Page): Promise<void> {
await page.route('**/v1/**', async (route: Route) => {
const request = route.request();
const url = request.url();
const path = new URL(url).pathname;

if (CLERKS_RE.test(url)) return json(route, CLERKS);
if (DATA_RE.test(url)) return json(route, ISSUE_DATA);
if (THREAD_RE.test(url) && request.method() === 'GET') return json(route, { messages: MESSAGES });

if (
request.method() === 'GET' &&
['/v1/language', '/v1/fiat', '/v1/asset', '/v1/bankAccount', '/v1/country'].includes(path)
) {
return json(route, []);
}
if (request.method() === 'GET' && path === '/v1/setting/infoBanner') {
return json(route, null);
}
if (request.method() === 'GET' && path === '/v1/support/issue/clerk') {
return json(route, { clerkUserDataId: 1, clerk: 'Rita Clerk' });
}

await route.continue();
});

await page.route('**/v2/**', async (route: Route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() === 'GET' && path === '/v2/user') {
return json(route, {
id: 1,
activeAddress: { address: '0x0000000000000000000000000000000000000001', wallet: 'DFX' },
addresses: [],
kyc: { level: 50, status: 'Completed' },
language: { id: 1, name: 'English', symbol: 'EN' },
});
}
await route.continue();
});
}

test.describe('Support Dashboard - issue detail', () => {
const token = jwt();

test('issue screen shows detail panels, clerk select and message thread', async ({ page }) => {
await installIssueRoutes(page);

await page.goto(`/support/dashboard/issue/${ISSUE_ID}?session=${encodeURIComponent(token)}&lang=en`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1500);

await expect(page.getByText('Issue Details')).toBeVisible();
await expect(page.getByText(ISSUE_UID)).toBeVisible();
await expect(page.locator('select').filter({ hasText: 'Rita Clerk' })).toHaveValue('101');

await expect(page).toHaveScreenshot('support-dashboard-02-issue.png', {
fullPage: true,
maxDiffPixels: 5000,
});
});
});
2 changes: 1 addition & 1 deletion scripts/handbook/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
},
"support-dashboard": {
"title": "Support-Dashboard",
"description": "Support-Dashboard: Übersicht und Detailansichten."
"description": "Support-Dashboard: Übersicht der offenen Tickets und die Ticket-Detailansicht mit Clerk-Zuweisung."
},
"support-dashboard-overview": {
"title": "Support-Dashboard Übersicht",
Expand Down
98 changes: 94 additions & 4 deletions src/__tests__/realunit-dashboard.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ jest.mock('src/util/utils', () => ({
import { ResponseType } from '@dfx.swiss/react';
import { useRealunitCompliance } from '../hooks/realunit-compliance.hook';
import { useRealunitSupport } from '../hooks/realunit-support.hook';
import {
clerkAssignmentPayload,
isAssignedToMe,
LEFTOVER_CLERK_VALUE,
usableClerks,
} from '../hooks/support-dashboard.hook';

describe('useRealunitSupport', () => {
beforeEach(() => {
Expand Down Expand Up @@ -60,11 +66,11 @@ describe('useRealunitSupport', () => {
await result.current.getIssueData(42);
expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/support/42/data', method: 'GET' });

await result.current.updateIssue(42, { state: 'Completed', clerk: 'Alice' });
await result.current.updateIssue(42, { state: 'Completed', clerkUserDataId: 9 });
expect(mockCall).toHaveBeenCalledWith({
url: 'realunit/support/42',
method: 'PUT',
data: { state: 'Completed', clerk: 'Alice' },
data: { state: 'Completed', clerkUserDataId: 9 },
});

await result.current.createMessage(42, { author: 'Alice', message: 'hi' });
Expand All @@ -88,14 +94,35 @@ describe('useRealunitSupport', () => {
expect(messages).toEqual([{ id: 1, author: 'Alice', created: 'now' }]);
});

it('getClerks returns { userDataId, name }[] from GET realunit/support/clerks', async () => {
mockCall.mockResolvedValue([{ userDataId: 3, name: 'Alex' }]);
const { result } = renderHook(() => useRealunitSupport());

const clerks = await result.current.getClerks();

expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/support/clerks', method: 'GET' });
expect(clerks).toEqual([{ userDataId: 3, name: 'Alex' }]);
});

it('getClerks drops entries without a finite userDataId', async () => {
mockCall.mockResolvedValue([
{ userDataId: 3, name: 'Alex' },
{ userDataId: Number.NaN, name: 'Broken' },
{ name: 'NoId' },
]);
const { result } = renderHook(() => useRealunitSupport());

await expect(result.current.getClerks()).resolves.toEqual([{ userDataId: 3, name: 'Alex' }]);
});

it('getMyClerk GETs realunit/support/clerk and trims the clerk name', async () => {
mockCall.mockResolvedValue({ clerk: ' Ada ' });
mockCall.mockResolvedValue({ clerkUserDataId: 7, clerk: ' Ada ' });
const { result } = renderHook(() => useRealunitSupport());

const clerk = await result.current.getMyClerk();

expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/support/clerk', method: 'GET' });
expect(clerk).toBe('Ada');
expect(clerk).toEqual({ clerkUserDataId: 7, clerk: 'Ada' });
});

it('getMyClerk returns undefined when clerk is null or blank', async () => {
Expand All @@ -109,6 +136,69 @@ describe('useRealunitSupport', () => {
});
});

describe('clerkAssignmentPayload', () => {
it('omits the field when the selected clerk is unchanged', () => {
expect(clerkAssignmentPayload('101', 101)).toEqual({});
});

it('sends the id when assigning a different clerk', () => {
expect(clerkAssignmentPayload('102', 101)).toEqual({ clerkUserDataId: 102 });
});

it('sends null when clearing an existing assignment', () => {
expect(clerkAssignmentPayload('', 101)).toEqual({ clerkUserDataId: null });
});

it('omits the field when already unassigned and the select is empty', () => {
expect(clerkAssignmentPayload('', null)).toEqual({});
expect(clerkAssignmentPayload('')).toEqual({});
});

it('sends null when the leftover name is still set and the select is empty', () => {
expect(clerkAssignmentPayload('', null, { leftover: true })).toEqual({ clerkUserDataId: null });
});

it('omits the field while the leftover name is still selected', () => {
expect(clerkAssignmentPayload(LEFTOVER_CLERK_VALUE, null, { leftover: true })).toEqual({});
});

it('omits the field when the selected value is not a finite id', () => {
expect(clerkAssignmentPayload('undefined', 101)).toEqual({});
expect(clerkAssignmentPayload('NaN', 101)).toEqual({});
});

it('omits the field when the id is not on the allow list', () => {
expect(clerkAssignmentPayload('99', null, { allowedIds: [101, 102] })).toEqual({});
expect(clerkAssignmentPayload('101', null, { allowedIds: [101, 102] })).toEqual({ clerkUserDataId: 101 });
});
});

describe('isAssignedToMe', () => {
it('matches the JWT account even when the leftover name differs', () => {
expect(isAssignedToMe({ clerkUserDataId: 7, clerk: 'Josh' }, 7, 'JOSHUA BEN KRUEGER')).toBe(true);
});

it('matches a leftover name when the id is still missing', () => {
expect(isAssignedToMe({ clerk: 'Ada' }, 7, 'Ada')).toBe(true);
});

it('does not match a leftover name against a different session', () => {
expect(isAssignedToMe({ clerkUserDataId: 9, clerk: 'Ada' }, 7, 'Ada')).toBe(false);
});
});

describe('usableClerks', () => {
it('keeps only entries with a finite userDataId and a name', () => {
expect(
usableClerks([
{ userDataId: 1, name: 'Ada' },
{ userDataId: Number.NaN, name: 'Bad' },
{ userDataId: 2, name: '' },
]),
).toEqual([{ userDataId: 1, name: 'Ada' }]);
});
});

describe('useRealunitCompliance', () => {
beforeEach(() => {
mockCall.mockReset().mockResolvedValue(undefined);
Expand Down
Loading
Loading