Full test infrastructure, code audit fixes, and real E2E integration tests
Test infrastructure: - Django standalone test runner (pytest-django, test settings, EmailUser model) - React unit tests via Vitest with jsdom, jest compat layer, path aliases - Playwright E2E tests using generated hooks in a real Chromium browser - Docker Compose test backend (Django + Redis) for integration testing - Desktop integration test app (PyWebView + Django + uvicorn) - Makefile with test/test-django/test-react/test-integration targets Library bugs found and fixed: - hasJWT truthiness: undefined !== null was true, skipping session init - process.env crash: CSR client referenced process.env in non-Node browsers - baseUrl not forwarded: DjareaProvider didn't pass baseUrl to CSR client - Relative URL handling: new URL() failed with relative base paths - call() race condition: HTTP requests fired before CSRF cookie was set - Session init await: added sessionRef promise so call() waits for session - path_prefix on schema export: both export commands failed with URL reverse - NullBooleanField removed: referenced field doesn't exist in Django 5.0+ - lru_cache on JWT settings: get_settings() now cached as intended - Channel message routing: broadcasts now include channel name and params - httpFunctionCall: fixed URL and request body format Generator fixes: - Removed 1,100 lines of REST/OpenAPI client generation (not part of Djarea) - Generator now works for djarea-only projects without django-ninja REST APIs - Generated DjangoContext now includes ChannelProvider when channels exist - Fixed env var passthrough for schema export commands - Deduplicated fetch logic into single runDjangoCommand helper Test quality: - Fixed 33 tautological Django tests with real assertions - Found hidden bug: benchmark functions were never registered - Found hidden bug: unicode lookalike test used plain ASCII - Deleted worthless React unit tests (duplicates, shape checks, Zod-tests-Zod) - Replaced jsdom integration tests with Playwright browser tests Example apps: - example/: Integration test backend with 33 server functions, 5 forms, 4 channels covering auth variations, contexts, class-based ServerFunction, error codes, DjareaFormMixin, formsets, and JWT - desktop/: PyWebView desktop app with file system access, SQLite CRUD, system introspection, and 39 real HTTP integration tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
824
react/src/__tests__/integration.test.tsx
Normal file
824
react/src/__tests__/integration.test.tsx
Normal file
@@ -0,0 +1,824 @@
|
||||
/**
|
||||
* 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 (
|
||||
<DjareaProvider baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
{children}
|
||||
</DjareaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<void>((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<void>((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<any>((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<any>((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<any>((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<void>((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<any>((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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user