@luxexchange/sessions 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.depcheckrc +20 -0
  2. package/.eslintrc.js +21 -0
  3. package/README.md +1 -0
  4. package/env.d.ts +12 -0
  5. package/package.json +50 -0
  6. package/project.json +42 -0
  7. package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
  8. package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
  9. package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
  10. package/src/challenge-solvers/createHashcashSolver.ts +255 -0
  11. package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
  12. package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
  13. package/src/challenge-solvers/createTurnstileSolver.ts +353 -0
  14. package/src/challenge-solvers/hashcash/core.native.ts +34 -0
  15. package/src/challenge-solvers/hashcash/core.test.ts +314 -0
  16. package/src/challenge-solvers/hashcash/core.ts +35 -0
  17. package/src/challenge-solvers/hashcash/core.web.ts +123 -0
  18. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
  19. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
  20. package/src/challenge-solvers/hashcash/shared.ts +70 -0
  21. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
  22. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
  23. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
  24. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
  25. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
  26. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
  27. package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
  28. package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
  29. package/src/challenge-solvers/turnstileErrors.ts +49 -0
  30. package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
  31. package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
  32. package/src/challenge-solvers/types.ts +55 -0
  33. package/src/challengeFlow.integration.test.ts +627 -0
  34. package/src/device-id/createDeviceIdService.ts +19 -0
  35. package/src/device-id/types.ts +11 -0
  36. package/src/index.ts +137 -0
  37. package/src/lux-identifier/createLuxIdentifierService.ts +19 -0
  38. package/src/lux-identifier/luxIdentifierQuery.ts +20 -0
  39. package/src/lux-identifier/types.ts +11 -0
  40. package/src/oauth-service/createOAuthService.ts +125 -0
  41. package/src/oauth-service/types.ts +104 -0
  42. package/src/performance/createNoopPerformanceTracker.ts +12 -0
  43. package/src/performance/createPerformanceTracker.ts +43 -0
  44. package/src/performance/index.ts +7 -0
  45. package/src/performance/types.ts +11 -0
  46. package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
  47. package/src/session-initialization/createSessionInitializationService.ts +193 -0
  48. package/src/session-initialization/sessionErrors.ts +32 -0
  49. package/src/session-repository/createSessionClient.ts +10 -0
  50. package/src/session-repository/createSessionRepository.test.ts +313 -0
  51. package/src/session-repository/createSessionRepository.ts +242 -0
  52. package/src/session-repository/errors.ts +22 -0
  53. package/src/session-repository/types.ts +289 -0
  54. package/src/session-service/createNoopSessionService.ts +24 -0
  55. package/src/session-service/createSessionService.test.ts +388 -0
  56. package/src/session-service/createSessionService.ts +61 -0
  57. package/src/session-service/types.ts +59 -0
  58. package/src/session-storage/createSessionStorage.ts +28 -0
  59. package/src/session-storage/types.ts +15 -0
  60. package/src/session.integration.test.ts +480 -0
  61. package/src/sessionLifecycle.integration.test.ts +264 -0
  62. package/src/test-utils/createLocalCookieTransport.ts +52 -0
  63. package/src/test-utils/createLocalHeaderTransport.ts +45 -0
  64. package/src/test-utils/mocks.ts +122 -0
  65. package/src/test-utils.ts +200 -0
  66. package/tsconfig.json +19 -0
  67. package/tsconfig.lint.json +8 -0
  68. package/tsconfig.spec.json +8 -0
  69. package/vitest.config.ts +20 -0
  70. package/vitest.integration.config.ts +14 -0
@@ -0,0 +1,193 @@
1
+ import type { ChallengeSolverService } from '@luxexchange/sessions/src/challenge-solvers/types'
2
+ import type { PerformanceTracker } from '@luxexchange/sessions/src/performance/types'
3
+ import {
4
+ MaxChallengeRetriesError,
5
+ NoSolverAvailableError,
6
+ } from '@luxexchange/sessions/src/session-initialization/sessionErrors'
7
+ import type { SessionService } from '@luxexchange/sessions/src/session-service/types'
8
+ import type { Logger } from '@luxfi/utilities/src/logger/logger'
9
+
10
+ interface SessionInitResult {
11
+ sessionId: string | null
12
+ }
13
+
14
+ /**
15
+ * Callbacks for session initialization lifecycle events.
16
+ * Each callback is optional and focused on one event.
17
+ */
18
+ export interface SessionInitAnalytics {
19
+ /** Called when session initialization starts */
20
+ onInitStarted?: () => void
21
+ /** Called when session initialization completes (before challenge flow) */
22
+ onInitCompleted?: (data: { needChallenge: boolean; durationMs: number }) => void
23
+ /** Called when a challenge is received from the backend */
24
+ onChallengeReceived?: (data: { challengeType: string; challengeId: string }) => void
25
+ /** Called when session verification completes (success or retry) */
26
+ onVerifyCompleted?: (data: { success: boolean; attemptNumber: number; totalDurationMs: number }) => void
27
+ }
28
+
29
+ interface SessionInitOptions {
30
+ /**
31
+ * If provided, skips the initSession() RPC call and uses this value directly.
32
+ * Useful when the caller already knows the answer (e.g. from a prior init call).
33
+ */
34
+ needChallenge?: boolean
35
+ }
36
+
37
+ interface SessionInitializationService {
38
+ /**
39
+ * Orchestrates the complete session initialization flow:
40
+ * 1. Calls initSession (backend decides whether to create new or reuse existing)
41
+ * 2. Handles challenge solving if required
42
+ *
43
+ * Pass `options.needChallenge` to skip step 1 when the answer is already known.
44
+ *
45
+ * @throws Error if initialization fails
46
+ */
47
+ initialize(options?: SessionInitOptions): Promise<SessionInitResult>
48
+ }
49
+
50
+ function createSessionInitializationService(ctx: {
51
+ getSessionService: () => SessionService
52
+ challengeSolverService: ChallengeSolverService
53
+ /**
54
+ * Required: Performance tracker for timing measurements.
55
+ * Must be injected - no implicit dependency on globalThis.performance.
56
+ */
57
+ performanceTracker: PerformanceTracker
58
+ getIsSessionUpgradeAutoEnabled?: () => boolean
59
+ maxChallengeRetries?: number
60
+ getLogger?: () => Logger
61
+ /** Analytics callbacks for tracking session initialization lifecycle */
62
+ analytics?: SessionInitAnalytics
63
+ }): SessionInitializationService {
64
+ async function handleChallengeFlow(attemptCount = 0, flowStartTime?: number): Promise<void> {
65
+ const startTime = flowStartTime ?? ctx.performanceTracker.now()
66
+ const maxRetries = ctx.maxChallengeRetries ?? 3
67
+
68
+ const challenge = await ctx.getSessionService().requestChallenge()
69
+
70
+ ctx.getLogger?.().debug('createSessionInitializationService', 'handleChallengeFlow', 'Requesting challenge', {
71
+ challenge,
72
+ })
73
+
74
+ // Report challenge received (only on first attempt)
75
+ if (attemptCount === 0) {
76
+ ctx.analytics?.onChallengeReceived?.({
77
+ challengeType: String(challenge.challengeType),
78
+ challengeId: challenge.challengeId,
79
+ })
80
+ }
81
+
82
+ // get our solver for the challenge type
83
+ const solver = ctx.challengeSolverService.getSolver(challenge.challengeType)
84
+ if (!solver) {
85
+ throw new NoSolverAvailableError(challenge.challengeType)
86
+ }
87
+
88
+ // Solve the challenge — if the solver throws (e.g. Turnstile domain mismatch on
89
+ // Vercel previews), submit a placeholder solution so verifySession can reject it
90
+ // and the retry loop can request a different challenge type (typically Hashcash).
91
+ // Note: we use a non-empty placeholder because proto3 omits empty strings from the
92
+ // wire, which means the backend wouldn't see the solution field at all.
93
+ let solution: string
94
+ try {
95
+ solution = await solver.solve({
96
+ challengeId: challenge.challengeId,
97
+ challengeType: challenge.challengeType,
98
+ extra: challenge.extra,
99
+ challengeData: challenge.challengeData,
100
+ })
101
+ } catch (solverError) {
102
+ ctx
103
+ .getLogger?.()
104
+ .warn(
105
+ 'createSessionInitializationService',
106
+ 'handleChallengeFlow',
107
+ 'Solver failed, submitting placeholder solution to trigger fallback',
108
+ { error: solverError, challengeType: challenge.challengeType },
109
+ )
110
+ solution = 'solver-failed'
111
+ }
112
+
113
+ ctx
114
+ .getLogger?.()
115
+ .debug('createSessionInitializationService', 'handleChallengeFlow', 'Solved challenge', { solution })
116
+
117
+ // Verify session with the solution
118
+ const result = await ctx.getSessionService().verifySession({
119
+ solution,
120
+ challengeId: challenge.challengeId,
121
+ challengeType: challenge.challengeType,
122
+ })
123
+
124
+ if (!result.retry) {
125
+ // Verification was successful
126
+ ctx.analytics?.onVerifyCompleted?.({
127
+ success: true,
128
+ attemptNumber: attemptCount + 1,
129
+ totalDurationMs: ctx.performanceTracker.now() - startTime,
130
+ })
131
+ return
132
+ }
133
+
134
+ // Report retry (verification failed but will retry)
135
+ ctx.analytics?.onVerifyCompleted?.({
136
+ success: false,
137
+ attemptNumber: attemptCount + 1,
138
+ totalDurationMs: ctx.performanceTracker.now() - startTime,
139
+ })
140
+
141
+ // Handle server retry request
142
+ if (attemptCount >= maxRetries) {
143
+ throw new MaxChallengeRetriesError(maxRetries, attemptCount + 1)
144
+ }
145
+
146
+ await handleChallengeFlow(attemptCount + 1, startTime) // Recursive call with incremented count
147
+ }
148
+
149
+ async function initialize(options?: SessionInitOptions): Promise<SessionInitResult> {
150
+ const initStartTime = ctx.performanceTracker.now()
151
+
152
+ let needChallenge: boolean
153
+ let sessionId: string | undefined
154
+
155
+ if (options?.needChallenge !== undefined) {
156
+ // Caller already knows — skip the initSession() RPC
157
+ needChallenge = options.needChallenge
158
+ sessionId = undefined
159
+
160
+ ctx.analytics?.onInitCompleted?.({
161
+ needChallenge,
162
+ durationMs: 0,
163
+ })
164
+ } else {
165
+ // Discover from backend
166
+ ctx.analytics?.onInitStarted?.()
167
+
168
+ const initResponse = await ctx.getSessionService().initSession()
169
+ needChallenge = initResponse.needChallenge
170
+ sessionId = initResponse.sessionId
171
+
172
+ ctx.analytics?.onInitCompleted?.({
173
+ needChallenge,
174
+ durationMs: ctx.performanceTracker.now() - initStartTime,
175
+ })
176
+ }
177
+
178
+ // Handle challenge if required and enabled
179
+ if (needChallenge && ctx.getIsSessionUpgradeAutoEnabled?.()) {
180
+ await handleChallengeFlow()
181
+ }
182
+
183
+ // sessionId is null for web (stored in cookie), real ID for non-web platforms
184
+ return {
185
+ sessionId: sessionId ?? null,
186
+ }
187
+ }
188
+
189
+ return { initialize }
190
+ }
191
+
192
+ export { createSessionInitializationService }
193
+ export type { SessionInitializationService, SessionInitOptions, SessionInitResult }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Base class for session-related errors that should not trigger retries at higher levels.
3
+ */
4
+ export abstract class SessionError extends Error {
5
+ constructor(message: string, name: string) {
6
+ super(message)
7
+ this.name = name
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Error thrown when maximum challenge retry attempts are exceeded.
13
+ * This error should not trigger additional retries at higher levels.
14
+ */
15
+ export class MaxChallengeRetriesError extends SessionError {
16
+ constructor(maxRetries: number, actualAttempts: number) {
17
+ super(
18
+ `Maximum challenge retry attempts (${maxRetries}) exceeded after ${actualAttempts} attempts`,
19
+ 'MaxChallengeRetriesError',
20
+ )
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Error thrown when no solver is available for a challenge type.
26
+ * This error should not trigger additional retries at higher levels.
27
+ */
28
+ export class NoSolverAvailableError extends SessionError {
29
+ constructor(challengeType: number) {
30
+ super(`No solver available for challenge type: ${challengeType}`, 'NoSolverAvailableError')
31
+ }
32
+ }
@@ -0,0 +1,10 @@
1
+ import { createPromiseClient, type PromiseClient, type Transport } from '@connectrpc/connect'
2
+ import { SessionService } from '@luxdex/client-platform-service/dist/uniswap/platformservice/v1/sessionService_connect'
3
+
4
+ type SessionServiceClient = PromiseClient<typeof SessionService>
5
+
6
+ function createSessionClient(ctx: { transport: Transport }): PromiseClient<typeof SessionService> {
7
+ return createPromiseClient(SessionService, ctx.transport)
8
+ }
9
+
10
+ export { type SessionServiceClient, createSessionClient }
@@ -0,0 +1,313 @@
1
+ import type { PromiseClient } from '@connectrpc/connect'
2
+ import type { SessionService } from '@uniswap/client-platform-service/dist/uniswap/platformservice/v1/sessionService_connect'
3
+ import {
4
+ ChallengeFailure,
5
+ ChallengeFailure_Reason,
6
+ ChallengeResponse,
7
+ GetChallengeTypesResponse,
8
+ InitSessionResponse,
9
+ SignoutResponse,
10
+ } from '@uniswap/client-platform-service/dist/uniswap/platformservice/v1/sessionService_pb'
11
+ import { createSessionRepository } from '@luxexchange/sessions/src/session-repository/createSessionRepository'
12
+ import { ChallengeRejectedError } from '@luxexchange/sessions/src/session-repository/errors'
13
+ import { ChallengeType } from '@luxexchange/sessions/src/session-service/types'
14
+ import { describe, expect, it, type MockedFunction, vi } from 'vitest'
15
+
16
+ type MockedClient = {
17
+ [K in keyof PromiseClient<typeof SessionService>]: MockedFunction<PromiseClient<typeof SessionService>[K]>
18
+ }
19
+
20
+ describe('createSessionRepository', () => {
21
+ const createMockClient = (): MockedClient => ({
22
+ initSession: vi.fn().mockResolvedValue({
23
+ sessionId: 'test-session-123',
24
+ deviceId: 'test-device-123',
25
+ needChallenge: false,
26
+ extra: {},
27
+ }),
28
+ challenge: vi.fn().mockResolvedValue({
29
+ challengeId: 'challenge-123',
30
+ challengeType: 1,
31
+ extra: { sitekey: 'test-key' },
32
+ challengeData: { case: undefined },
33
+ }),
34
+ verify: vi.fn().mockResolvedValue({
35
+ retry: false,
36
+ outcome: { case: 'success' as const, value: {} },
37
+ }),
38
+ updateSession: vi.fn().mockResolvedValue({}),
39
+ deleteSession: vi.fn().mockResolvedValue({}),
40
+ introspectSession: vi.fn().mockResolvedValue({}), // Required by proto but not used
41
+ getChallengeTypes: vi.fn().mockResolvedValue(new GetChallengeTypesResponse({ challengeTypes: [] })),
42
+ signout: vi.fn().mockResolvedValue(new SignoutResponse({})),
43
+ })
44
+
45
+ describe('session initialization behaviors', () => {
46
+ it('initializes a session and returns session data', async () => {
47
+ const mockClient = createMockClient()
48
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
49
+
50
+ const result = await repository.initSession()
51
+
52
+ expect(result).toEqual({
53
+ sessionId: 'test-session-123',
54
+ deviceId: 'test-device-123',
55
+ needChallenge: false,
56
+ extra: {},
57
+ })
58
+ })
59
+
60
+ it('handles web sessions without session ID', async () => {
61
+ const mockClient = createMockClient()
62
+ mockClient.initSession.mockResolvedValue(
63
+ new InitSessionResponse({
64
+ sessionId: undefined,
65
+ deviceId: undefined,
66
+ needChallenge: true,
67
+ extra: { sitekey: 'turnstile-key' },
68
+ }),
69
+ )
70
+
71
+ const repository = createSessionRepository({ client: mockClient })
72
+ const result = await repository.initSession()
73
+
74
+ // Web sessions don't return sessionId or deviceId (managed by cookies)
75
+ expect(result.sessionId).toBeUndefined()
76
+ expect(result.deviceId).toBeUndefined()
77
+ expect(result.needChallenge).toBe(true)
78
+ expect(result.extra).toEqual({ sitekey: 'turnstile-key' })
79
+ })
80
+
81
+ it('provides meaningful error when initialization fails', async () => {
82
+ const mockClient = createMockClient()
83
+ mockClient.initSession.mockRejectedValue(new Error('Network error'))
84
+
85
+ const repository = createSessionRepository({ client: mockClient })
86
+
87
+ await expect(repository.initSession()).rejects.toThrow('Failed to initialize session')
88
+ })
89
+ })
90
+
91
+ describe('bot detection behaviors', () => {
92
+ it('retrieves challenge information for bot detection', async () => {
93
+ const mockClient = createMockClient()
94
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
95
+
96
+ const result = await repository.challenge({})
97
+
98
+ expect(result).toEqual({
99
+ challengeId: 'challenge-123',
100
+ challengeType: 1,
101
+ extra: { sitekey: 'test-key' },
102
+ challengeData: { case: undefined },
103
+ authorizeUrl: undefined,
104
+ })
105
+ })
106
+
107
+ it('throws ChallengeRejectedError when response has failure field', async () => {
108
+ const mockClient = createMockClient()
109
+ mockClient.challenge.mockResolvedValue(
110
+ new ChallengeResponse({
111
+ failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.UNSPECIFIED }),
112
+ }),
113
+ )
114
+
115
+ const repository = createSessionRepository({ client: mockClient })
116
+
117
+ await expect(repository.challenge({})).rejects.toThrow(ChallengeRejectedError)
118
+ await expect(repository.challenge({})).rejects.toThrow('REASON_UNSPECIFIED')
119
+ })
120
+
121
+ it('throws ChallengeRejectedError with BOT_DETECTION_REQUIRED reason', async () => {
122
+ const mockClient = createMockClient()
123
+ mockClient.challenge.mockResolvedValue(
124
+ new ChallengeResponse({
125
+ failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.BOT_DETECTION_REQUIRED }),
126
+ }),
127
+ )
128
+
129
+ const repository = createSessionRepository({ client: mockClient })
130
+
131
+ await expect(repository.challenge({})).rejects.toThrow(ChallengeRejectedError)
132
+ await expect(repository.challenge({})).rejects.toThrow('REASON_BOT_DETECTION_REQUIRED')
133
+ })
134
+
135
+ it('does NOT throw ChallengeRejectedError when no failure field is present', async () => {
136
+ const mockClient = createMockClient()
137
+ mockClient.challenge.mockResolvedValue(new ChallengeResponse({ challengeId: 'some-id', challengeType: 0 }))
138
+
139
+ const repository = createSessionRepository({ client: mockClient })
140
+ const result = await repository.challenge({})
141
+
142
+ expect(result.challengeId).toBe('some-id')
143
+ })
144
+
145
+ it('ChallengeRejectedError has typed reason and rawFailure fields', async () => {
146
+ const mockClient = createMockClient()
147
+ mockClient.challenge.mockResolvedValue(
148
+ new ChallengeResponse({
149
+ failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.BOT_DETECTION_REQUIRED }),
150
+ }),
151
+ )
152
+
153
+ const repository = createSessionRepository({ client: mockClient })
154
+
155
+ try {
156
+ await repository.challenge({})
157
+ expect.fail('Should have thrown')
158
+ } catch (error) {
159
+ expect(error).toBeInstanceOf(ChallengeRejectedError)
160
+ const rejected = error as ChallengeRejectedError
161
+ expect(rejected.reason).toBe('REASON_BOT_DETECTION_REQUIRED')
162
+ expect(rejected.rawFailure).toBeDefined()
163
+ expect(rejected.name).toBe('ChallengeRejectedError')
164
+ }
165
+ })
166
+
167
+ it('provides meaningful error when challenge request fails', async () => {
168
+ const mockClient = createMockClient()
169
+ mockClient.challenge.mockRejectedValue(new Error('API error'))
170
+
171
+ const repository = createSessionRepository({ client: mockClient })
172
+
173
+ await expect(repository.challenge({})).rejects.toThrow('Failed to get challenge')
174
+ })
175
+ })
176
+
177
+ describe('session verify behaviors', () => {
178
+ it('submits bot detection solution', async () => {
179
+ const mockClient = createMockClient()
180
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
181
+
182
+ const result = await repository.verifySession({
183
+ solution: 'solution-token',
184
+ challengeId: 'challenge-123',
185
+ challengeType: ChallengeType.TURNSTILE,
186
+ })
187
+
188
+ // Verify the client was called correctly (challengeType maps to 'type' in the backend API)
189
+ expect(mockClient.verify).toHaveBeenCalledWith({
190
+ solution: 'solution-token',
191
+ challengeId: 'challenge-123',
192
+ type: ChallengeType.TURNSTILE,
193
+ })
194
+
195
+ // Should return retry status
196
+ expect(result).toEqual({ retry: false })
197
+ })
198
+
199
+ it('handles verify with additional parameters', async () => {
200
+ const mockClient = createMockClient()
201
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
202
+
203
+ const result = await repository.verifySession({
204
+ solution: 'solution-token',
205
+ challengeId: 'challenge-123',
206
+ challengeType: ChallengeType.TURNSTILE,
207
+ })
208
+
209
+ // Should succeed with required params
210
+ expect(result).toEqual({ retry: false })
211
+ })
212
+
213
+ it('treats undefined outcome with retry=false as success (proto3 dropped empty VerifySuccess)', async () => {
214
+ const mockClient = createMockClient()
215
+ mockClient.verify.mockResolvedValue({
216
+ retry: false,
217
+ outcome: { case: undefined, value: undefined },
218
+ })
219
+
220
+ const repository = createSessionRepository({ client: mockClient })
221
+
222
+ const result = await repository.verifySession({
223
+ solution: 'solution-token',
224
+ challengeId: 'challenge-123',
225
+ challengeType: ChallengeType.TURNSTILE,
226
+ })
227
+
228
+ // Proto3 drops empty VerifySuccess — retry: false signals success
229
+ expect(result.retry).toBe(false)
230
+ })
231
+
232
+ it('allows undefined outcome when retry is true (valid retry-only response)', async () => {
233
+ const mockClient = createMockClient()
234
+ mockClient.verify.mockResolvedValue({
235
+ retry: true,
236
+ outcome: { case: undefined, value: undefined },
237
+ })
238
+
239
+ const repository = createSessionRepository({ client: mockClient })
240
+
241
+ const result = await repository.verifySession({
242
+ solution: 'solution-token',
243
+ challengeId: 'challenge-123',
244
+ challengeType: ChallengeType.TURNSTILE,
245
+ })
246
+
247
+ expect(result.retry).toBe(true)
248
+ })
249
+
250
+ it('returns failure info from verify failure outcome', async () => {
251
+ const mockClient = createMockClient()
252
+ mockClient.verify.mockResolvedValue({
253
+ retry: true,
254
+ outcome: {
255
+ case: 'failure' as const,
256
+ value: {
257
+ reason: 1, // INVALID_SOLUTION
258
+ message: 'Bad code',
259
+ waitSeconds: 30,
260
+ },
261
+ },
262
+ })
263
+
264
+ const repository = createSessionRepository({ client: mockClient })
265
+
266
+ const result = await repository.verifySession({
267
+ solution: 'bad-code',
268
+ challengeId: 'challenge-123',
269
+ challengeType: ChallengeType.TURNSTILE,
270
+ })
271
+
272
+ expect(result.retry).toBe(true)
273
+ expect(result.failureReason).toBe('REASON_INVALID_SOLUTION')
274
+ expect(result.failureMessage).toBe('Bad code')
275
+ expect(result.waitSeconds).toBe(30)
276
+ })
277
+
278
+ it('provides meaningful error when verify fails', async () => {
279
+ const mockClient = createMockClient()
280
+ mockClient.verify.mockRejectedValue(new Error('Invalid solution'))
281
+
282
+ const repository = createSessionRepository({ client: mockClient })
283
+
284
+ await expect(
285
+ repository.verifySession({
286
+ solution: 'bad-token',
287
+ challengeId: 'challenge-123',
288
+ challengeType: ChallengeType.TURNSTILE,
289
+ }),
290
+ ).rejects.toThrow('Failed to verify session')
291
+ })
292
+ })
293
+
294
+ describe('session cleanup behaviors', () => {
295
+ it('deletes session successfully', async () => {
296
+ const mockClient = createMockClient()
297
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
298
+
299
+ const result = await repository.deleteSession({})
300
+
301
+ expect(result).toEqual({})
302
+ })
303
+
304
+ it('provides meaningful error when deletion fails', async () => {
305
+ const mockClient = createMockClient()
306
+ mockClient.signout.mockRejectedValue(new Error('Server error'))
307
+
308
+ const repository = createSessionRepository({ client: mockClient })
309
+
310
+ await expect(repository.deleteSession({})).rejects.toThrow('Failed to delete session')
311
+ })
312
+ })
313
+ })