/**
* 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')
})
})