Skip to content

Commit 6a2eea6

Browse files
committed
refactor(journey-client): use Either for response parsing in start and next
1 parent 091667c commit 6a2eea6

9 files changed

Lines changed: 179 additions & 95 deletions

File tree

e2e/journey-suites/src/login.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,61 @@ import { expect, test } from '@playwright/test';
99
import { asyncEvents } from './utils/async-events.js';
1010
import { password, username } from './utils/demo-user.js';
1111

12+
test('renders login failure when wrong password is submitted', async ({ page }) => {
13+
const { clickButton, navigate } = asyncEvents(page);
14+
await navigate('/?journey=Login');
15+
16+
const errorMessages: string[] = [];
17+
18+
page.on('console', (msg) => {
19+
if (msg.type() === 'error') {
20+
errorMessages.push(msg.text());
21+
}
22+
});
23+
24+
await page.getByLabel('User Name').fill(username);
25+
await page.getByLabel('Password').fill('wrongpassword');
26+
await clickButton('Submit', '/authenticate');
27+
28+
await expect(page.locator('#errorMessage')).toBeVisible();
29+
expect(errorMessages.some((msg) => msg.includes('Journey failed'))).toBe(true);
30+
});
31+
32+
test('renders login failure when unknown user is submitted', async ({ page }) => {
33+
const { clickButton, navigate } = asyncEvents(page);
34+
await navigate('/?journey=Login');
35+
36+
const errorMessages: string[] = [];
37+
38+
page.on('console', (msg) => {
39+
if (msg.type() === 'error') {
40+
errorMessages.push(msg.text());
41+
}
42+
});
43+
44+
await page.getByLabel('User Name').fill('nonexistentuser');
45+
await page.getByLabel('Password').fill('somepassword');
46+
await clickButton('Submit', '/authenticate');
47+
48+
await expect(page.locator('#errorMessage')).toBeVisible();
49+
expect(errorMessages.some((msg) => msg.includes('Journey failed'))).toBe(true);
50+
});
51+
52+
test('re-renders form after login failure so user can retry', async ({ page }) => {
53+
const { clickButton, navigate } = asyncEvents(page);
54+
await navigate('/?journey=Login');
55+
56+
await page.getByLabel('User Name').fill(username);
57+
await page.getByLabel('Password').fill('wrongpassword');
58+
await clickButton('Submit', '/authenticate');
59+
60+
await expect(page.locator('#errorMessage')).toBeVisible();
61+
62+
await expect(page.getByLabel('User Name')).toBeVisible();
63+
await expect(page.getByLabel('Password')).toBeVisible();
64+
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
65+
});
66+
1267
test('Test happy paths on test page', async ({ page }) => {
1368
const { clickButton, navigate } = asyncEvents(page);
1469
await navigate('/?journey=Login');

packages/journey-client/api-report/journey-client.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export type JourneyLoginSuccess = AuthResponse & {
229229
getSuccessUrl: () => string | undefined;
230230
};
231231

232-
// @public
232+
// @public (undocumented)
233233
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
234234

235235
// @public

packages/journey-client/api-report/journey-client.types.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ export type JourneyLoginSuccess = AuthResponse & {
216216
getSuccessUrl: () => string | undefined;
217217
};
218218

219-
// @public
219+
// @public (undocumented)
220220
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
221221

222222
// @public

packages/journey-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@forgerock/sdk-utilities": "workspace:*",
4040
"@forgerock/storage": "workspace:*",
4141
"@reduxjs/toolkit": "catalog:",
42+
"effect": "catalog:effect",
4243
"tslib": "catalog:"
4344
},
4445
"devDependencies": {

packages/journey-client/src/lib/client.store.ts

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,15 @@ import { createJourneyStore } from './client.store.utils.js';
2121
import { configSlice } from './config.slice.js';
2222
import { journeyApi } from './journey.api.js';
2323
import { createStorage } from '@forgerock/storage';
24-
import { createJourneyObject, handleJourneyResponse } from './journey.utils.js';
24+
import { match } from 'effect/Either';
25+
import { createJourneyObject, parseJourneyResponse } from './journey.utils.js';
26+
import type { JourneyResult } from './journey.utils.js';
2527
import { wellknownApi } from './wellknown.api.js';
2628

2729
import type { JourneyStep } from './step.utils.js';
2830
import type { JourneyClientConfig } from './config.types.js';
2931
import type { RedirectCallback } from './callbacks/redirect-callback.js';
3032
import type { NextOptions, StartParam, ResumeOptions } from './interfaces.js';
31-
import type { JourneyLoginFailure } from './login-failure.utils.js';
32-
import type { JourneyLoginSuccess } from './login-success.utils.js';
33-
34-
/** Result type for journey client methods. */
35-
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
3633

3734
/** The journey client instance returned by the `journey()` function. */
3835
export interface JourneyClient {
@@ -158,28 +155,22 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
158155
subscribe: store.subscribe,
159156

160157
start: async (options?: StartParam) => {
161-
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
162-
const result = handleJourneyResponse(data, error);
163-
if ('error' in result) {
164-
return result;
165-
}
166-
167-
return createJourneyObject(result);
158+
const response = await store.dispatch(journeyApi.endpoints.start.initiate(options));
159+
return match(parseJourneyResponse(response), {
160+
onLeft: (err): JourneyResult => err,
161+
onRight: (step): JourneyResult => createJourneyObject(step),
162+
});
168163
},
169164

170165
/**
171166
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
172167
*/
173168
next: async (step: JourneyStep, options?: NextOptions) => {
174-
const { data, error } = await store.dispatch(
175-
journeyApi.endpoints.next.initiate({ step, options }),
176-
);
177-
const result = handleJourneyResponse(data, error);
178-
if ('error' in result) {
179-
return result;
180-
}
181-
182-
return createJourneyObject(result);
169+
const response = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options }));
170+
return match(parseJourneyResponse(response), {
171+
onLeft: (err): JourneyResult => err,
172+
onRight: (step): JourneyResult => createJourneyObject(step),
173+
});
183174
},
184175

185176
// TODO: Remove the actual redirect from this method and just return the URL to the caller

packages/journey-client/src/lib/journey.utils.test.ts

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
1010
import { StepType } from '../types.js';
1111
import { type Step } from '../index.js';
1212

13-
import { createJourneyObject, handleJourneyResponse } from './journey.utils.js';
13+
import { createJourneyObject, parseJourneyResponse } from './journey.utils.js';
1414
import type { JourneyLoginFailure } from './login-failure.utils.js';
1515

1616
describe('createJourneyObject', () => {
@@ -63,85 +63,99 @@ describe('createJourneyObject', () => {
6363
});
6464
});
6565

66-
describe('handleJourneyResponse', () => {
67-
it('returns Step data when FetchBaseQueryError has numeric status and object body', () => {
66+
describe('parseJourneyResponse', () => {
67+
it('returns right(Step) when FetchBaseQueryError has numeric status and object body', () => {
6868
const body = { code: 401, message: 'Access Denied', reason: 'Unauthorized' };
6969
const error = { status: 401, data: body };
7070

71-
const result = handleJourneyResponse(undefined, error);
71+
const result = parseJourneyResponse({ data: undefined, error });
7272

73-
expect(result).toBe(body);
73+
expect(result._tag).toBe('Right');
74+
expect((result as { right: unknown }).right).toBe(body);
7475
});
7576

76-
it('returns GenericError when FetchBaseQueryError has numeric status but non-object body', () => {
77+
it('returns left(GenericError) when FetchBaseQueryError has numeric status but non-object body', () => {
7778
const error = { status: 500, data: 'Internal Server Error' };
7879

79-
const result = handleJourneyResponse(undefined, error);
80+
const result = parseJourneyResponse({ data: undefined, error });
8081

81-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
82+
expect(result._tag).toBe('Left');
83+
expect((result as { left: unknown }).left).toMatchObject({
84+
error: 'request_failed',
85+
type: 'unknown_error',
86+
});
8287
});
8388

84-
it('returns GenericError for FETCH_ERROR', () => {
89+
it('returns left(GenericError) for FETCH_ERROR', () => {
8590
const error = { status: 'FETCH_ERROR' as const, error: 'Network error' };
8691

87-
const result = handleJourneyResponse(undefined, error);
92+
const result = parseJourneyResponse({ data: undefined, error });
8893

89-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
90-
expect((result as { message: string }).message).toContain('Network error');
94+
expect(result._tag).toBe('Left');
95+
expect((result as { left: { message: string } }).left.message).toContain('Network error');
9196
});
9297

93-
it('returns GenericError for PARSING_ERROR', () => {
98+
it('returns left(GenericError) for PARSING_ERROR', () => {
9499
const error = {
95100
status: 'PARSING_ERROR' as const,
96101
originalStatus: 200,
97102
data: '<html>Not JSON</html>',
98103
error: 'JSON parse error',
99104
};
100105

101-
const result = handleJourneyResponse(undefined, error);
106+
const result = parseJourneyResponse({ data: undefined, error });
102107

103-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
104-
expect((result as { message: string }).message).toContain('JSON parse error');
108+
expect(result._tag).toBe('Left');
109+
expect((result as { left: { message: string } }).left.message).toContain('JSON parse error');
105110
});
106111

107-
it('returns GenericError for TIMEOUT_ERROR', () => {
112+
it('returns left(GenericError) for TIMEOUT_ERROR', () => {
108113
const error = { status: 'TIMEOUT_ERROR' as const, error: 'Request timed out' };
109114

110-
const result = handleJourneyResponse(undefined, error);
115+
const result = parseJourneyResponse({ data: undefined, error });
111116

112-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
113-
expect((result as { message: string }).message).toContain('Request timed out');
117+
expect(result._tag).toBe('Left');
118+
expect((result as { left: { message: string } }).left.message).toContain('Request timed out');
114119
});
115120

116-
it('returns GenericError for CUSTOM_ERROR', () => {
121+
it('returns left(GenericError) for CUSTOM_ERROR', () => {
117122
const error = { status: 'CUSTOM_ERROR' as const, error: 'Custom error occurred' };
118123

119-
const result = handleJourneyResponse(undefined, error);
124+
const result = parseJourneyResponse({ data: undefined, error });
120125

121-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
122-
expect((result as { message: string }).message).toContain('Custom error occurred');
126+
expect(result._tag).toBe('Left');
127+
expect((result as { left: { message: string } }).left.message).toContain(
128+
'Custom error occurred',
129+
);
123130
});
124131

125-
it('returns GenericError for SerializedError', () => {
132+
it('returns left(GenericError) for SerializedError', () => {
126133
const error = { name: 'Error', message: 'Something went wrong', stack: '...' };
127134

128-
const result = handleJourneyResponse(undefined, error);
135+
const result = parseJourneyResponse({ data: undefined, error });
129136

130-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
131-
expect((result as { message: string }).message).toContain('Something went wrong');
137+
expect(result._tag).toBe('Left');
138+
expect((result as { left: { message: string } }).left.message).toContain(
139+
'Something went wrong',
140+
);
132141
});
133142

134-
it('returns GenericError when no data and no error', () => {
135-
const result = handleJourneyResponse(undefined, undefined);
143+
it('returns left(GenericError) when no data and no error', () => {
144+
const result = parseJourneyResponse({ data: undefined, error: undefined });
136145

137-
expect(result).toMatchObject({ error: 'no_response_data', type: 'unknown_error' });
146+
expect(result._tag).toBe('Left');
147+
expect((result as { left: unknown }).left).toMatchObject({
148+
error: 'no_response_data',
149+
type: 'unknown_error',
150+
});
138151
});
139152

140-
it('returns data when no error and data is present', () => {
153+
it('returns right(Step) when no error and data is present', () => {
141154
const data: Step = { authId: 'test-auth-id', callbacks: [] };
142155

143-
const result = handleJourneyResponse(data, undefined);
156+
const result = parseJourneyResponse({ data, error: undefined });
144157

145-
expect(result).toBe(data);
158+
expect(result._tag).toBe('Right');
159+
expect((result as { right: unknown }).right).toBe(data);
146160
});
147161
});

0 commit comments

Comments
 (0)