/** * Cross-cutting integration tests for djarea * * Tests error paths and protocol correctness across HTTP, Forms, and WebSocket. * Requires a running backend: docker-compose up * * Run with: RUN_INTEGRATION_TESTS=true npm run test */ import { renderHook, act } from '@testing-library/react' import { ReactNode } from 'react' import { describeIntegration, BACKEND_URL, WS_URL } from '../testing' import { DjareaProvider, useDjarea } from '../context' import { DjangoError } from '../errors' import { ChannelConnection } from '../channels/connection' import { RPCError } from '../channels/connection' function Wrapper({ children }: { children: ReactNode }) { return ( {children} ) } // Helper to get call function function useCall() { const { call } = useDjarea() return call } // Helper to wait for a ChannelConnection to reach 'connected' status function waitForConnected(connection: ChannelConnection): Promise { return new Promise((resolve) => { if (connection.status === 'connected') { resolve(); return } const unsub = connection.onStatusChange((status) => { if (status === 'connected') { unsub(); resolve() } }) }) } // ============================================================================ // Group 1: Executor framework validation // ============================================================================ describeIntegration('Executor framework validation', () => { it('should return VALIDATION_ERROR with field details for wrong input types', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('add', { a: 'hello', b: 'world' }) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('VALIDATION_ERROR') expect(error!.isValidationError()).toBe(true) const fieldErrors = error!.getFieldErrors() expect(fieldErrors).not.toBeNull() expect(Object.keys(fieldErrors!).length).toBeGreaterThan(0) }) it('should return NOT_FOUND for non-existent function', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('this_function_does_not_exist', {}) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('NOT_FOUND') }) it('should return FORBIDDEN for auth-required function when anonymous', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('whoami', {}) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.isAuthError()).toBe(true) }) it('should return VALIDATION_ERROR with specific field for missing required input', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('echo', {}) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('VALIDATION_ERROR') const fieldErrors = error!.getFieldErrors() expect(fieldErrors).not.toBeNull() expect(fieldErrors!).toHaveProperty('text') }) }) // ============================================================================ // Group 2: Form framework validation // ============================================================================ describeIntegration('Form framework validation', () => { it('should return field metadata with types and required flags', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('login.schema', { data: {} }) }) expect(response).toHaveProperty('fields') // Each field should have name, label, type, required, widget const fields = response.fields for (const fieldKey of Object.keys(fields)) { const field = fields[fieldKey] expect(field).toHaveProperty('name') expect(field).toHaveProperty('label') expect(field).toHaveProperty('type') expect(field).toHaveProperty('required') expect(field).toHaveProperty('widget') } // login field should be required expect(fields.login.required).toBe(true) // password field widget should contain 'password' expect(fields.password.widget.toLowerCase()).toContain('password') }) it('should return field-level errors for empty form validation', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('login.validate', { data: {} }) }) expect(response.valid).toBe(false) expect(response.errors).toBeInstanceOf(Array) expect(response.errors.length).toBeGreaterThan(0) }) it('should return form-level error for wrong login credentials', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('login.submit', { login: 'wrong@example.com', password: 'wrongpass', }) }) expect(response.success).toBe(false) expect(JSON.stringify(response.errors)).toContain('Invalid login credentials') }) }) // ============================================================================ // Group 3: WebSocket framework validation // ============================================================================ describeIntegration('WebSocket framework validation', () => { let connection: ChannelConnection beforeEach(async () => { connection = new ChannelConnection({ url: WS_URL, reconnect: false }) connection.connect() // Wait for connected status await new Promise((resolve) => { const unsub = connection.onStatusChange((status) => { if (status === 'connected') { unsub() resolve() } }) if (connection.status === 'connected') { unsub() resolve() } }) }) afterEach(() => { connection.disconnect() }) it('should deliver messages back through channel subscription', async () => { // Subscribe to chat channel and wait for confirmation await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Subscribe timeout')), 5000) const unsub = connection.onMessage((msg) => { if ('subscribed' in msg && msg.channel === 'chat') { clearTimeout(timeout) unsub() resolve() } }) connection.send({ action: 'subscribe', channel: 'chat', params: { room: 'integration-test' }, }) }) // Listen for the echoed message const messagePromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Message timeout')), 5000) const unsub = connection.onMessage((msg) => { if ('data' in msg) { clearTimeout(timeout) unsub() resolve(msg) } }) }) // Send a message connection.send({ action: 'message', channel: 'chat', params: { room: 'integration-test' }, data: { text: 'hello from integration test' }, }) const received = await messagePromise expect(received.data).toHaveProperty('text') expect(received.data.text).toBe('hello from integration test') }) it('should return error for unknown channel subscription', async () => { const errorPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Error response timeout')), 5000) const unsub = connection.onMessage((msg) => { if ('error' in msg) { clearTimeout(timeout) unsub() resolve(msg) } }) }) connection.send({ action: 'subscribe', channel: 'nonexistent_channel', params: {}, }) const errorMsg = await errorPromise expect(errorMsg.error.toLowerCase()).toContain('unknown channel') }) it('should reject HTTP-only function via WebSocket RPC', async () => { let rpcError: RPCError | null = null try { await connection.rpc('http_only_echo', { text: 'test' }) } catch (e) { rpcError = e as RPCError } expect(rpcError).toBeInstanceOf(RPCError) }) it('should return NOT_FOUND for non-existent RPC function', async () => { let rpcError: RPCError | null = null try { await connection.rpc('does_not_exist', {}) } catch (e) { rpcError = e as RPCError } expect(rpcError).toBeInstanceOf(RPCError) expect(rpcError!.code).toBe('NOT_FOUND') }) it('should return VALIDATION_ERROR for wrong RPC input types', async () => { let rpcError: RPCError | null = null try { await connection.rpc('add', { a: 'not_number', b: 'also_not' }) } catch (e) { rpcError = e as RPCError } expect(rpcError).toBeInstanceOf(RPCError) expect(rpcError!.code).toBe('VALIDATION_ERROR') }) }) // ============================================================================ // Group 4: HTTP happy path // ============================================================================ describeIntegration('HTTP happy path', () => { it('should call echo and receive echoed text', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('echo', { text: 'hello world' }) }) expect(response).toHaveProperty('message') expect(response.message).toContain('hello world') }) it('should call add and receive correct sum', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('add', { a: 17, b: 25 }) }) expect(response).toEqual({ result: 42 }) }) it('should call class-based ServerFunction (multiply)', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('multiply', { x: 7, y: 6 }) }) expect(response).toEqual({ product: 42 }) }) }) // ============================================================================ // Group 5: Auth variations // ============================================================================ describeIntegration('Auth variations', () => { it('should reject staff_only for anonymous with UNAUTHORIZED', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('staff_only', {}) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('UNAUTHORIZED') expect(error!.isAuthError()).toBe(true) }) it('should reject superuser_only for anonymous with UNAUTHORIZED', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('superuser_only', {}) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('UNAUTHORIZED') expect(error!.isAuthError()).toBe(true) }) it('should reject verified_only for anonymous (callable auth)', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('verified_only', {}) } catch (e) { error = e as DjangoError } }) // Callable auth returns False for anonymous, which maps to FORBIDDEN expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('FORBIDDEN') expect(error!.isAuthError()).toBe(true) }) }) // ============================================================================ // Group 6: Context functions // ============================================================================ describeIntegration('Context functions', () => { it('should call global context current_user and get anonymous response', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('current_user', {}) }) expect(response).toHaveProperty('authenticated', false) expect(response).toHaveProperty('email', '') expect(response).toHaveProperty('is_staff', false) }) it('should call local context greet with name parameter', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('greet', { name: 'World' }) }) expect(response).toHaveProperty('greeting', 'Hello, World!') }) }) // ============================================================================ // Group 7: Error code coverage // ============================================================================ describeIntegration('Error code coverage', () => { it('should return NOT_IMPLEMENTED for NotImplementedError', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('not_implemented_fn', {}) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('NOT_IMPLEMENTED') }) it('should return INTERNAL_ERROR for unhandled RuntimeError', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('buggy_fn', {}) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('INTERNAL_ERROR') }) it('should return FORBIDDEN for PermissionError with wrong secret', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let error: DjangoError | null = null await act(async () => { try { await result.current('permission_check_fn', { secret: 'wrong' }) } catch (e) { error = e as DjangoError } }) expect(error).toBeInstanceOf(DjangoError) expect(error!.code).toBe('FORBIDDEN') expect(error!.isAuthError()).toBe(true) }) it('should succeed with correct secret for permission_check_fn', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('permission_check_fn', { secret: 'open-sesame' }) }) expect(response).toEqual({ message: 'access granted' }) }) it('should return BAD_REQUEST for invalid JSON body', async () => { const response = await fetch(`${BACKEND_URL}/api/djarea/call/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: '{not valid json}', }) const data = await response.json() expect(data.error).toBe(true) expect(data.code).toBe('BAD_REQUEST') }) it('should return BAD_REQUEST for missing fn field', async () => { const response = await fetch(`${BACKEND_URL}/api/djarea/call/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ args: {} }), }) const data = await response.json() expect(data.error).toBe(true) expect(data.code).toBe('BAD_REQUEST') }) }) // ============================================================================ // Group 8: DjareaFormMixin integration // ============================================================================ describeIntegration('DjareaFormMixin integration', () => { it('should return schema with title, subtitle, and submit_label from DjareaFormMeta', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('contact.schema', { data: {} }) }) expect(response).toHaveProperty('fields') const fields = response.fields // Contact form should have name, email, and message fields expect(fields).toHaveProperty('name') expect(fields).toHaveProperty('email') expect(fields).toHaveProperty('message') // Meta should include title, subtitle, and submit_label expect(response).toHaveProperty('meta') expect(response.meta.title).toBe('Contact Us') expect(response.meta).toHaveProperty('subtitle') expect(response.meta.submit_label).toBe('Send Message') }) it('should return form meta with live_validation and live_form_errors', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('contact.schema', { data: {} }) }) expect(response).toHaveProperty('meta') expect(response.meta.live_validation).toBe(true) expect(response.meta.live_form_errors).toBe(false) }) it('should validate contact form and return field errors for missing fields', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('contact.validate', { data: {} }) }) expect(response.valid).toBe(false) expect(response.errors).toBeInstanceOf(Array) expect(response.errors.length).toBeGreaterThan(0) // Should have errors for name, email, and message const errorFieldNames = response.errors.map((e: any) => e.field) expect(errorFieldNames).toContain('name') expect(errorFieldNames).toContain('email') expect(errorFieldNames).toContain('message') }) it('should submit contact form successfully and get on_submit_success data', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('contact.submit', { name: 'Test', email: 'test@test.com', message: 'Hello', }) }) expect(response.success).toBe(true) expect(response.data).toHaveProperty('received', true) expect(response.data).toHaveProperty('from', 'test@test.com') }) }) // ============================================================================ // Group 9: Formset integration // ============================================================================ describeIntegration('Formset integration', () => { it('should return formset schema for item form', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('item.formset.schema', { forms: [] }) }) expect(response).toHaveProperty('min_num') expect(response).toHaveProperty('max_num') }) it('should validate formset with invalid data', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('item.formset.validate', { forms: [{ label: '', quantity: 0 }], }) }) // Should have validation errors for the invalid form data expect(response).toHaveProperty('errors') }) it('should submit formset with valid data', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) let response: any = null await act(async () => { response = await result.current('item.formset.submit', { forms: [{ label: 'Widget', quantity: 5 }], }) }) expect(response).toHaveProperty('success', true) }) }) // ============================================================================ // Group 10: Channel authorization // ============================================================================ describeIntegration('Channel authorization', () => { let connection: ChannelConnection beforeEach(async () => { connection = new ChannelConnection({ url: WS_URL, reconnect: false }) connection.connect() await waitForConnected(connection) }) afterEach(() => { connection.disconnect() }) it('should reject subscription to private channel when anonymous', async () => { const msgPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Auth rejection timeout')), 5000) connection.onMessage((msg) => { if ('error' in msg) { clearTimeout(timeout) resolve(msg) } }) }) connection.send({ action: 'subscribe', channel: 'private' }) const msg = await msgPromise expect(msg.error).toContain('Not authorized') }) it('should successfully unsubscribe from a channel', async () => { // First subscribe to chat await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Subscribe timeout')), 5000) const unsub = connection.onMessage((msg) => { if ('subscribed' in msg && msg.channel === 'chat') { clearTimeout(timeout) unsub() resolve() } }) connection.send({ action: 'subscribe', channel: 'chat', params: { room: 'unsub-test' }, }) }) // Now unsubscribe const unsubPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Unsubscribe timeout')), 5000) const unsub = connection.onMessage((msg) => { if ('unsubscribed' in msg && msg.channel === 'chat') { clearTimeout(timeout) unsub() resolve(msg) } }) }) connection.send({ action: 'unsubscribe', channel: 'chat', params: { room: 'unsub-test' }, }) const unsubMsg = await unsubPromise expect(unsubMsg).toHaveProperty('unsubscribed', true) expect(unsubMsg).toHaveProperty('channel', 'chat') }) }) // ============================================================================ // Group 11: WebSocket RPC happy path // ============================================================================ describeIntegration('WebSocket RPC happy path', () => { let connection: ChannelConnection beforeEach(async () => { connection = new ChannelConnection({ url: WS_URL, reconnect: false }) connection.connect() await waitForConnected(connection) }) afterEach(() => { connection.disconnect() }) it('should call echo via RPC and get correct response', async () => { const response = await connection.rpc<{ text: string }, { message: string }>( 'echo', { text: 'ws rpc echo' } ) expect(response).toHaveProperty('message') expect(response.message).toContain('ws rpc echo') }) it('should call add via RPC and get correct sum', async () => { const response = await connection.rpc<{ a: number; b: number }, { result: number }>( 'add', { a: 100, b: 200 } ) expect(response).toEqual({ result: 300 }) }) it('should reject multiply via RPC if not websocket-enabled', async () => { // multiply uses @register_as which may not set websocket=True // If it's HTTP-only, RPC should fail; if it supports WS, it should succeed let response: any = null let rpcError: RPCError | null = null try { response = await connection.rpc<{ x: number; y: number }, { product: number }>( 'multiply', { x: 7, y: 6 } ) } catch (e) { rpcError = e as RPCError } // Either it succeeds with the correct product, or it fails because it's HTTP-only if (rpcError) { expect(rpcError).toBeInstanceOf(RPCError) } else { expect(response).toEqual({ product: 42 }) } }) it('should reject ws_whoami via RPC when anonymous', async () => { let rpcError: RPCError | null = null try { await connection.rpc('ws_whoami', {}) } catch (e) { rpcError = e as RPCError } expect(rpcError).toBeInstanceOf(RPCError) expect(rpcError!.code).toBe('UNAUTHORIZED') }) }) // ============================================================================ // Group 12: Successful form submit flow // ============================================================================ describeIntegration('Form submit success flow', () => { it('should sign up a new user via signup form', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) // Use a unique email per run to avoid duplicate-user errors const uniqueEmail = `newuser+${Date.now()}@example.com` let response: any = null await act(async () => { response = await result.current('signup.submit', { email: uniqueEmail, password1: 'testpass123', }) }) expect(response).toHaveProperty('success', true) expect(response).toHaveProperty('data') expect(response.data).toHaveProperty('user_id') expect(typeof response.data.user_id).toBe('number') }) })