A channel's message slots are named from the client, on every backend

The IR called them react-message and django-message, so a FastAPI channel had
to declare a DjangoMessage. They are client-message and server-message now,
and the direction words hold wherever a channel is declared: Params /
ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and
friends so no backend names a type itself. Django's ReactChannel and
FastAPI's ReactChannel are both Channel.

mizan-fastapi never registered a channels extension, so build_ir() emitted no
channel at all and every payload type was invisible to codegen. It registers
one now. RegistryExtension is an ABC requiring all(), which is what the IR
reads — an extension that cannot enumerate its registrations no longer exists.

The gate that should have caught the rename could not: tests/afi registered no
channel because mizan-rust had no channel registry to register one in, so a
five-package rename of the wire contract passed byte-parity without a channel
byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a
#[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's
split; the AFI fixture now carries a channel with every slot and one with a
single slot, so all three backends prove the contract byte for byte.

MizanChannel held three Option<String> beside three has_*() predicates and
unwrapped them with defaults; it holds an ordered slot vector, so an absent
slot is absent rather than defaulted. The channels target emitted a React
hooks file that a stage1-only consumer could not compile — react emits that
now. The codegen's parity tests byte-compared emitted source against baselines
without ever compiling it: they compile the generated crate and run its tests,
import the generated Python package and call every method, and typecheck each
TypeScript target against a consumer.

Also fixed at source: app_visitor printed its import diagnostic to stdout, the
stream export_mizan_ir writes KDL to, so a failed import silently corrupted the
IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on
any non-JSON-serializable field default; Django and mizan-core derived Pascal
names two different ways, disagreeing on every dotted channel name.

ir.py builds a document and renders templates/ir/document.kdl.j2 rather than
appending KDL strings with hand-tracked indentation, and named types resolve to
a fixed point — a model reachable only through a union branch was referenced by
a ref that no type block ever defined.

The rest is the write-gate's own classifiers run over the standing tree:
relative imports, silent swallows, Protocol contracts that should be ABCs,
emitters hand-rendering target source, catch-all arms over closed enums, and
comments narrating the project rather than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,32 +1,15 @@
/**
* Tests for Django Server React Context
*
* Unit tests run without backend.
* Integration tests require: docker-compose up
*
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
*/
import React from 'react'
import { render, screen, waitFor, act } from '@testing-library/react'
import { render, renderHook, screen, act } from '@testing-library/react'
import { vi } from 'vitest'
import type { ReactNode } from 'react'
import {
MizanProvider,
useMizan,
useMizanStatus,
useMizanCall,
// Legacy aliases for backwards compatibility tests
MizanProvider,
useDjango,
useMizanStatus,
useMizanCall,
} from '../context'
import { MizanError } from '../errors'
import { describeIntegration, BACKEND_URL } from '../testing'
// ============================================================================
// Unit Tests (no backend required)
// ============================================================================
describe('mizan Context (unit)', () => {
describe('useMizan hook', () => {
it('should throw when used outside provider', () => {
@@ -35,7 +18,7 @@ describe('mizan Context (unit)', () => {
return <div>Test</div>
}
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<TestComponent />)).toThrow(
'useMizan must be used within a MizanProvider'
@@ -45,7 +28,7 @@ describe('mizan Context (unit)', () => {
})
it('should return context value inside provider', () => {
let contextValue: any = null
let contextValue: ReturnType<typeof useMizan> | null = null
function TestComponent() {
contextValue = useMizan()
@@ -82,7 +65,7 @@ describe('mizan Context (unit)', () => {
describe('hydration', () => {
it('should initialize context store from hydration data', () => {
let contextValue: any = null
let contextValue: ReturnType<typeof useMizan> | null = null
function TestComponent() {
contextValue = useMizan()
@@ -100,215 +83,105 @@ describe('mizan Context (unit)', () => {
</MizanProvider>
)
expect(contextValue.getContext('auth_status')).toEqual({ is_authenticated: false })
expect(contextValue.getContext('user')).toEqual(null)
expect(contextValue!.getContext('auth_status')).toEqual({ is_authenticated: false })
expect(contextValue!.getContext('user')).toEqual(null)
})
})
})
// ============================================================================
// Integration Tests (require running backend)
// ============================================================================
function Wrapper({ children }: { children: ReactNode }) {
return (
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
{children}
</MizanProvider>
)
}
describeIntegration('mizan Context (integration)', () => {
describe('server function calls via HTTP', () => {
it('should call echo function and get response', async () => {
let result: any = null
let error: any = null
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
function TestComponent() {
const { call, status } = useMizan()
let response: { message: string } | null = null
await act(async () => {
response = await result.current<{ text: string }, { message: string }>(
'echo',
{ text: 'context test' }
)
})
React.useEffect(() => {
// Use HTTP fallback (status will be disconnected without WebSocket)
call<{ text: string }, { message: string }>('echo', { text: 'context test' })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div data-testid="status">{status}</div>
}
render(
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</MizanProvider>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(error).toBeNull()
expect(result).toHaveProperty('message')
expect(result.message).toContain('context test')
expect(response).toHaveProperty('message')
expect(response!.message).toContain('context test')
})
it('should call add function with correct result', async () => {
let result: any = null
let error: any = null
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
function TestComponent() {
const { call } = useMizan()
let response: { result: number } | null = null
await act(async () => {
response = await result.current<{ a: number; b: number }, { result: number }>(
'add',
{ a: 10, b: 20 }
)
})
React.useEffect(() => {
call<{ a: number; b: number }, { result: number }>('add', { a: 10, b: 20 })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</MizanProvider>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(error).toBeNull()
expect(result).toEqual({ result: 30 })
expect(response).toEqual({ result: 30 })
})
it('should throw MizanError for validation errors', async () => {
let result: any = null
let error: any = null
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
function TestComponent() {
const { call } = useMizan()
React.useEffect(() => {
// Call without required field
call('echo', {})
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</MizanProvider>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(result).toBeNull()
expect(error).toBeInstanceOf(MizanError)
// `echo` requires `text`; omitting it must reject rather than resolve.
await expect(result.current('echo', {})).rejects.toBeInstanceOf(MizanError)
})
})
describe('useMizanCall hook', () => {
it('should create typed function that calls backend', async () => {
let result: any = null
let error: any = null
interface EchoInput { text: string }
interface EchoOutput { message: string }
function TestComponent() {
const echo = useMizanCall<EchoInput, EchoOutput>('echo')
React.useEffect(() => {
echo({ text: 'typed function test' })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [echo])
return <div>Test</div>
}
render(
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</MizanProvider>
const { result } = renderHook(
() => useMizanCall<EchoInput, EchoOutput>('echo'),
{ wrapper: Wrapper }
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
let response: EchoOutput | null = null
await act(async () => {
response = await result.current({ text: 'typed function test' })
})
expect(error).toBeNull()
expect(result).toHaveProperty('message')
expect(result.message).toContain('typed function test')
expect(response).toHaveProperty('message')
expect(response!.message).toContain('typed function test')
})
})
describe('form functions', () => {
it('should call login.schema and get form fields', async () => {
let result: any = null
let error: any = null
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
function TestComponent() {
const { call } = useMizan()
let response: any = null
await act(async () => {
response = await result.current('login.schema', { data: {} })
})
React.useEffect(() => {
call('login.schema', { data: {} })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</MizanProvider>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(error).toBeNull()
expect(result).toHaveProperty('fields')
expect(result).toHaveProperty('meta')
// Login form should have login and password fields
expect(result.fields).toHaveProperty('login')
expect(result.fields).toHaveProperty('password')
expect(response).toHaveProperty('fields')
expect(response).toHaveProperty('meta')
expect(response.fields).toHaveProperty('login')
expect(response.fields).toHaveProperty('password')
})
it('should call login.validate and get validation result', async () => {
let result: any = null
let error: any = null
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
function TestComponent() {
const { call } = useMizan()
let response: any = null
await act(async () => {
response = await result.current('login.validate', {
data: { login: 'test@example.com', password: 'testpass' },
})
})
React.useEffect(() => {
call('login.validate', {
data: { login: 'test@example.com', password: 'testpass' }
})
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</MizanProvider>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
// Should return validation result (may have errors for invalid creds, that's ok)
expect(error).toBeNull()
expect(result).toHaveProperty('valid')
expect(response).toHaveProperty('valid')
})
})
})

View File

@@ -1,7 +1,3 @@
/**
* Tests for Django Server Error
*/
import { MizanError, type FunctionErrorResponse } from '../errors'
describe('MizanError', () => {

View File

@@ -1,45 +1,26 @@
/**
* Tests for Django Forms
*
* Integration tests call the REAL backend - no mocks.
* Backend must be running: docker-compose up
*
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
*/
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { z } from 'zod'
import {
useDjangoFormCore,
useMizanFormCore,
type FormCoreConfig,
} from '../forms'
import { DjangoContext } from '../context'
import { MizanProvider } from '../context'
import { describeIntegration, BACKEND_URL } from '../testing'
// ============================================================================
// Test Setup
// ============================================================================
// Helper to render hook with provider
function renderFormHook<TData extends Record<string, unknown>>(
config: FormCoreConfig<TData>
) {
return renderHook(() => useDjangoFormCore<TData>(config), {
return renderHook(() => useMizanFormCore<TData>(config), {
wrapper: ({ children }) => (
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
{children}
</DjangoContext>
</MizanProvider>
),
})
}
// ============================================================================
// Integration Tests - Real Backend Calls
// ============================================================================
describeIntegration('useDjangoFormCore (integration)', () => {
describeIntegration('useMizanFormCore (integration)', () => {
describe('Schema loading from real backend', () => {
it('loads login form schema', async () => {
const { result } = renderFormHook({
@@ -54,7 +35,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.schema).not.toBeNull()
expect(result.current.schema?.name).toBe('login')
// Login form should have login and password fields
expect(result.current.schema?.fields).toHaveProperty('login')
expect(result.current.schema?.fields).toHaveProperty('password')
})
@@ -70,7 +50,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.schema).not.toBeNull()
expect(result.current.schema?.name).toBe('signup')
// Signup form should have email and password fields
expect(result.current.schema?.fields).toHaveProperty('email')
expect(result.current.schema?.fields).toHaveProperty('password1')
})
@@ -155,7 +134,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
})
describe('Zod validation with real schema', () => {
// Define Zod schema matching login form
const LoginZodSchema = z.object({
login: z.string().min(1, 'Login is required').email('Invalid email'),
password: z.string().min(1, 'Password is required'),
@@ -173,17 +151,14 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Set invalid value
act(() => {
result.current.set('login', 'not-an-email')
})
// Touch triggers validation
act(() => {
result.current.touch('login')
})
// Zod validation should show email format error
expect(result.current.errors?.fields.login).toBeDefined()
expect(result.current.errors?.fields.login?.[0]?.message).toBe('Invalid email')
expect(result.current.errors?.fields.login?.[0]?.source).toBe('zod')
@@ -199,7 +174,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Set invalid value and touch
act(() => {
result.current.set('login', 'bad')
})
@@ -209,7 +183,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.errors?.fields.login).toBeDefined()
// Set valid value and touch
act(() => {
result.current.set('login', 'valid@example.com')
})
@@ -232,7 +205,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.hasErrors).toBe(false)
// Set invalid and touch
act(() => {
result.current.set('login', 'bad')
})
@@ -242,7 +214,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.hasErrors).toBe(true)
// Set valid and touch
act(() => {
result.current.set('login', 'good@example.com')
})
@@ -264,21 +235,18 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Set invalid credentials
act(() => {
result.current.set('login', 'nonexistent@example.com')
result.current.set('password', 'wrongpassword')
})
// Submit should fail with validation error
let submitResult: any
let submitResult: Awaited<ReturnType<typeof result.current.submit>> | undefined
await act(async () => {
submitResult = await result.current.submit()
})
// Submit should return error (invalid credentials)
// The exact error depends on backend behavior
expect(submitResult).toBeDefined()
expect(submitResult!.success).toBe(false)
})
it('submits signup form with missing required fields', async () => {
@@ -290,15 +258,13 @@ describeIntegration('useDjangoFormCore (integration)', () => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Submit with empty fields should return validation errors
let submitResult: any
let submitResult: Awaited<ReturnType<typeof result.current.submit>> | undefined
await act(async () => {
submitResult = await result.current.submit()
})
// Should have validation errors for required fields
expect(submitResult).toBeDefined()
expect(submitResult.success).toBe(false)
expect(submitResult!.success).toBe(false)
})
})
@@ -349,14 +315,11 @@ describeIntegration('useDjangoFormCore (integration)', () => {
result.current.touch('login')
})
// Should have Zod errors
const zodErrors = result.current.getFieldErrors('login', { source: 'zod' })
expect(zodErrors.length).toBeGreaterThan(0)
// Should have no server errors yet
const serverErrors = result.current.getFieldErrors('login', { source: 'server' })
expect(serverErrors.length).toBe(0)
})
})
})

View File

@@ -1,19 +1,9 @@
/**
* Cross-cutting integration tests for mizan
*
* 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 { MizanProvider, useMizan } from '../context'
import { MizanError } from '../errors'
import { ChannelConnection } from '../channels/connection'
import { RPCError } from '../channels/connection'
import { ChannelConnection, RPCError } from '../channels/connection'
function Wrapper({ children }: { children: ReactNode }) {
return (
@@ -23,13 +13,11 @@ function Wrapper({ children }: { children: ReactNode }) {
)
}
// Helper to get call function
function useCall() {
const { call } = useMizan()
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 }
@@ -39,10 +27,6 @@ function waitForConnected(connection: ChannelConnection): Promise<void> {
})
}
// ============================================================================
// 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 })
@@ -116,10 +100,6 @@ describeIntegration('Executor framework validation', () => {
})
})
// ============================================================================
// 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 })
@@ -131,7 +111,6 @@ describeIntegration('Form framework validation', () => {
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]
@@ -142,10 +121,7 @@ describeIntegration('Form framework validation', () => {
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')
})
@@ -178,30 +154,13 @@ describeIntegration('Form framework validation', () => {
})
})
// ============================================================================
// 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()
}
})
await waitForConnected(connection)
})
afterEach(() => {
@@ -209,7 +168,6 @@ describeIntegration('WebSocket framework validation', () => {
})
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) => {
@@ -226,7 +184,6 @@ describeIntegration('WebSocket framework validation', () => {
})
})
// 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) => {
@@ -238,7 +195,6 @@ describeIntegration('WebSocket framework validation', () => {
})
})
// Send a message
connection.send({
action: 'message',
channel: 'chat',
@@ -309,10 +265,6 @@ describeIntegration('WebSocket framework validation', () => {
})
})
// ============================================================================
// Group 4: HTTP happy path
// ============================================================================
describeIntegration('HTTP happy path', () => {
it('should call echo and receive echoed text', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
@@ -349,10 +301,6 @@ describeIntegration('HTTP happy path', () => {
})
})
// ============================================================================
// Group 5: Auth variations
// ============================================================================
describeIntegration('Auth variations', () => {
it('should reject staff_only for anonymous with UNAUTHORIZED', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
@@ -388,7 +336,7 @@ describeIntegration('Auth variations', () => {
expect(error!.isAuthError()).toBe(true)
})
it('should reject verified_only for anonymous (callable auth)', async () => {
it('should reject verified_only for anonymous with FORBIDDEN', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: MizanError | null = null
@@ -400,17 +348,12 @@ describeIntegration('Auth variations', () => {
}
})
// Callable auth returns False for anonymous, which maps to FORBIDDEN
expect(error).toBeInstanceOf(MizanError)
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 })
@@ -437,10 +380,6 @@ describeIntegration('Context functions', () => {
})
})
// ============================================================================
// Group 7: Error code coverage
// ============================================================================
describeIntegration('Error code coverage', () => {
it('should return NOT_IMPLEMENTED for NotImplementedError', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
@@ -527,10 +466,6 @@ describeIntegration('Error code coverage', () => {
})
})
// ============================================================================
// Group 8: mizanFormMixin integration
// ============================================================================
describeIntegration('mizanFormMixin integration', () => {
it('should return schema with title, subtitle, and submit_label from mizanFormMeta', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
@@ -543,12 +478,10 @@ describeIntegration('mizanFormMixin integration', () => {
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')
@@ -580,7 +513,6 @@ describeIntegration('mizanFormMixin integration', () => {
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')
@@ -605,10 +537,6 @@ describeIntegration('mizanFormMixin integration', () => {
})
})
// ============================================================================
// Group 9: Formset integration
// ============================================================================
describeIntegration('Formset integration', () => {
it('should return formset schema for item form', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
@@ -632,7 +560,6 @@ describeIntegration('Formset integration', () => {
})
})
// Should have validation errors for the invalid form data
expect(response).toHaveProperty('errors')
})
@@ -650,10 +577,6 @@ describeIntegration('Formset integration', () => {
})
})
// ============================================================================
// Group 10: Channel authorization
// ============================================================================
describeIntegration('Channel authorization', () => {
let connection: ChannelConnection
@@ -685,7 +608,6 @@ describeIntegration('Channel authorization', () => {
})
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) => {
@@ -702,7 +624,6 @@ describeIntegration('Channel authorization', () => {
})
})
// Now unsubscribe
const unsubPromise = new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Unsubscribe timeout')), 5000)
const unsub = connection.onMessage((msg) => {
@@ -726,10 +647,6 @@ describeIntegration('Channel authorization', () => {
})
})
// ============================================================================
// Group 11: WebSocket RPC happy path
// ============================================================================
describeIntegration('WebSocket RPC happy path', () => {
let connection: ChannelConnection
@@ -762,13 +679,10 @@ describeIntegration('WebSocket RPC happy path', () => {
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
it('should reject multiply via RPC when it is not websocket-enabled', async () => {
let rpcError: RPCError | null = null
try {
response = await connection.rpc<{ x: number; y: number }, { product: number }>(
await connection.rpc<{ x: number; y: number }, { product: number }>(
'multiply',
{ x: 7, y: 6 }
)
@@ -776,12 +690,7 @@ describeIntegration('WebSocket RPC happy path', () => {
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 })
}
expect(rpcError).toBeInstanceOf(RPCError)
})
it('should reject ws_whoami via RPC when anonymous', async () => {
@@ -797,15 +706,11 @@ describeIntegration('WebSocket RPC happy path', () => {
})
})
// ============================================================================
// 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
// A fresh address per run keeps the backend from rejecting a duplicate user.
const uniqueEmail = `newuser+${Date.now()}@example.com`
let response: any = null

View File

@@ -0,0 +1,99 @@
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { WebSocketServer } from 'ws'
interface IncomingFrame {
action: string
channel: string
params?: Record<string, unknown>
data?: unknown
}
export interface ChannelTestServer {
url: string
close: () => Promise<void>
}
/**
* WebSocket server speaking the channel wire protocol: subscribe/unsubscribe are
* acknowledged, and a `message` frame on a live subscription comes back down as a
* channel message carrying the same payload.
*/
export async function startChannelServer(): Promise<ChannelTestServer> {
const http = createServer()
const wss = new WebSocketServer({ server: http, path: '/ws/' })
wss.on('connection', (socket) => {
const subscriptions = new Set<string>()
socket.on('message', (raw) => {
const frame = JSON.parse(String(raw)) as IncomingFrame
const params = frame.params ?? {}
const key = `${frame.channel}:${JSON.stringify(params)}`
if (frame.action === 'subscribe') {
subscriptions.add(key)
socket.send(JSON.stringify({
subscribed: true,
channel: frame.channel,
params,
}))
return
}
if (frame.action === 'unsubscribe') {
subscriptions.delete(key)
socket.send(JSON.stringify({
unsubscribed: true,
channel: frame.channel,
params,
}))
return
}
if (frame.action === 'message') {
if (!subscriptions.has(key)) {
socket.send(JSON.stringify({
error: `Not subscribed to ${frame.channel}`,
channel: frame.channel,
}))
return
}
socket.send(JSON.stringify({
channel: frame.channel,
params,
type: 'ServerMessage',
data: frame.data,
}))
return
}
socket.send(JSON.stringify({ error: `Unknown action: ${frame.action}` }))
})
})
await new Promise<void>((resolve) => {
http.listen(0, '127.0.0.1', resolve)
})
const { port } = http.address() as AddressInfo
return {
url: `ws://127.0.0.1:${port}/ws/`,
// An upgraded socket stays counted by the http server until its FIN is
// acknowledged, so closeAllConnections() is what lets close() call back.
close: () => new Promise<void>((resolve, reject) => {
wss.clients.forEach((client) => client.terminate())
wss.close()
http.closeAllConnections()
http.close((httpError) => {
if (httpError) {
reject(httpError)
return
}
resolve()
})
}),
}
}

View File

@@ -1,140 +1,130 @@
/**
* Tests for ChannelConnection
*
* These tests verify the ChannelConnection class API.
* Unit tests for class structure don't require a real backend.
* Integration tests for actual WebSocket connections require the backend.
*
* Backend must be running for integration tests: docker-compose up
*/
import { vi } from 'vitest'
import { ChannelConnection, RPCError } from '../connection'
import { describeIntegration, WS_URL } from '../../testing'
import { startChannelServer, type ChannelTestServer } from './channelServer'
describe('ChannelConnection', () => {
let server: ChannelTestServer
beforeAll(async () => {
server = await startChannelServer()
})
afterAll(async () => {
await server.close()
})
describe('ChannelConnection (unit tests)', () => {
describe('construction', () => {
it('should start in disconnected state', () => {
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
it('should start disconnected until connect is called', () => {
const connection = new ChannelConnection({ url: server.url, reconnect: false })
expect(connection.status).toBe('disconnected')
})
})
describe('status change handlers', () => {
it('should allow subscribing to status changes', () => {
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
const handler = jest.fn()
it('should report connecting then connected then disconnected', async () => {
const connection = new ChannelConnection({ url: server.url, reconnect: false })
const seen: string[] = []
connection.onStatusChange((status) => seen.push(status))
connection.connect()
await vi.waitFor(() => expect(connection.status).toBe('connected'), { timeout: 5000 })
connection.disconnect()
expect(seen.slice(0, 3)).toEqual(['connecting', 'connected', 'disconnected'])
})
it('should stop delivering status after the returned unsubscribe runs', async () => {
const connection = new ChannelConnection({ url: server.url, reconnect: false })
const handler = vi.fn()
const unsubscribe = connection.onStatusChange(handler)
expect(typeof unsubscribe).toBe('function')
unsubscribe()
connection.connect()
await vi.waitFor(() => expect(connection.status).toBe('connected'), { timeout: 5000 })
expect(handler).not.toHaveBeenCalled()
connection.disconnect()
})
})
describe('message handlers', () => {
it('should allow subscribing to messages', () => {
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
const handler = jest.fn()
it('should deliver server frames and stop after unsubscribe', async () => {
const connection = new ChannelConnection({ url: server.url, reconnect: false })
const received: unknown[] = []
const unsubscribe = connection.onMessage((payload) => received.push(payload))
const unsubscribe = connection.onMessage(handler)
connection.connect()
await vi.waitFor(() => expect(connection.status).toBe('connected'), { timeout: 5000 })
expect(typeof unsubscribe).toBe('function')
connection.send({ action: 'subscribe', channel: 'chat', params: { room: 'a' } })
await vi.waitFor(() => {
expect(received).toEqual([{ subscribed: true, channel: 'chat', params: { room: 'a' } }])
}, { timeout: 5000 })
unsubscribe()
const afterUnsubscribe: unknown[] = []
connection.onMessage((payload) => afterUnsubscribe.push(payload))
connection.send({ action: 'unsubscribe', channel: 'chat', params: { room: 'a' } })
await vi.waitFor(() => expect(afterUnsubscribe).toHaveLength(1), { timeout: 5000 })
expect(received).toHaveLength(1)
connection.disconnect()
})
})
describe('send queueing', () => {
it('should queue messages when not connected', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
it('should open the socket and flush frames sent while disconnected', async () => {
const connection = new ChannelConnection({ url: server.url, reconnect: false })
const received: unknown[] = []
connection.onMessage((payload) => received.push(payload))
// This shouldn't throw
connection.send({
action: 'subscribe',
channel: 'test',
params: {},
})
connection.send({ action: 'subscribe', channel: 'queued', params: {} })
// Status should still be disconnected (or connecting if it auto-connected)
expect(['disconnected', 'connecting']).toContain(connection.status)
expect(connection.status).toBe('connecting')
await vi.waitFor(() => {
expect(received).toEqual([{ subscribed: true, channel: 'queued', params: {} }])
}, { timeout: 5000 })
connection.disconnect()
})
it('should reject a message frame for a channel that was never subscribed', async () => {
const connection = new ChannelConnection({ url: server.url, reconnect: false })
const received: unknown[] = []
connection.onMessage((payload) => received.push(payload))
connection.send({ action: 'message', channel: 'ghost', params: {}, data: { text: 'hi' } })
await vi.waitFor(() => {
expect(received).toEqual([{ error: 'Not subscribed to ghost', channel: 'ghost' }])
}, { timeout: 5000 })
connection.disconnect()
})
})
describe('rpc', () => {
it('should queue rpc messages when not connected', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
it('should flush an rpc request queued before the socket opened', async () => {
const connection = new ChannelConnection({ url: server.url, reconnect: false })
const received: unknown[] = []
connection.onMessage((payload) => received.push(payload))
const promise = connection.rpc('test_fn', { arg: 'value' })
const pending = connection.rpc('test_fn', { arg: 'value' })
expect(promise).toBeInstanceOf(Promise)
})
})
})
expect(pending).toBeInstanceOf(Promise)
describeIntegration('ChannelConnection (integration)', () => {
describe('real WebSocket connection', () => {
it('should connect to real backend WebSocket', async () => {
const connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
await vi.waitFor(() => {
expect(received).toEqual([{ error: 'Unknown action: rpc' }])
}, { timeout: 5000 })
const statusChanges: string[] = []
connection.onStatusChange((status) => {
statusChanges.push(status)
})
// Connect
connection.connect()
// Wait for connection
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'))
}, 5000)
const unsubscribe = connection.onStatusChange((status) => {
if (status === 'connected') {
clearTimeout(timeout)
unsubscribe()
resolve()
}
})
})
expect(connection.status).toBe('connected')
// Cleanup
connection.disconnect()
})
it('should disconnect cleanly', async () => {
const connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
// Connect first
connection.connect()
await new Promise<void>((resolve) => {
const unsubscribe = connection.onStatusChange((status) => {
if (status === 'connected') {
unsubscribe()
resolve()
}
})
})
// Now disconnect
connection.disconnect()
// Should be disconnected
expect(connection.status).toBe('disconnected')
})
})
})
@@ -146,7 +136,7 @@ describe('RPCError', () => {
expect(error).toBeInstanceOf(RPCError)
})
it('should have correct properties', () => {
it('should carry code, message and details', () => {
const error = new RPCError('VALIDATION_ERROR', 'Field is required', { field: 'email' })
expect(error.code).toBe('VALIDATION_ERROR')
@@ -155,7 +145,7 @@ describe('RPCError', () => {
expect(error.name).toBe('RPCError')
})
it('should work without details', () => {
it('should leave details undefined when none are given', () => {
const error = new RPCError('NOT_FOUND', 'Function not found')
expect(error.code).toBe('NOT_FOUND')

View File

@@ -1,205 +1,134 @@
/**
* Tests for ChannelProvider context
*
* Unit tests run without backend.
* Integration tests require: docker-compose up
*
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
*/
import { renderHook, act, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ReactNode } from 'react'
import { ChannelProvider, useChannelContext, useChannelStatus } from '../context'
import { ChannelConnection } from '../connection'
import { describeIntegration, WS_URL } from '../../testing'
import { startChannelServer, type ChannelTestServer } from './channelServer'
// ============================================================================
// Unit Tests (no backend required)
// ============================================================================
function wrapperFor(connection: ChannelConnection, autoConnect: boolean) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={autoConnect}>
{children}
</ChannelProvider>
)
}
}
describe('ChannelProvider (unit)', () => {
describe('useChannelContext', () => {
it('should throw when used outside ChannelProvider', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
renderHook(() => useChannelContext())
}).toThrow('useChannelContext must be used within a ChannelProvider')
consoleSpy.mockRestore()
})
it('should return connection and status when inside provider', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelProvider connection={connection} autoConnect={false}>
{children}
</ChannelProvider>
)
const { result } = renderHook(() => useChannelContext(), { wrapper })
expect(result.current.connection).toBe(connection)
expect(result.current.status).toBe('disconnected')
connection.disconnect()
})
})
describe('useChannelStatus', () => {
it('should return disconnected when autoConnect is false', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelProvider connection={connection} autoConnect={false}>
{children}
</ChannelProvider>
)
const { result } = renderHook(() => useChannelStatus(), { wrapper })
expect(result.current).toBe('disconnected')
connection.disconnect()
})
})
})
// ============================================================================
// Integration Tests (require running backend)
// ============================================================================
describeIntegration('ChannelProvider (integration)', () => {
describe('with real WebSocket connection', () => {
let connection: ChannelConnection
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
})
afterEach(() => {
connection.disconnect()
})
const createWrapper = (autoConnect = true) => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider
connection={connection}
autoConnect={autoConnect}
>
{children}
</ChannelProvider>
)
}
}
it('should auto-connect when autoConnect is true', async () => {
const { result } = renderHook(() => useChannelContext(), {
wrapper: createWrapper(true),
})
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
})
it('should not auto-connect when autoConnect is false', () => {
const { result } = renderHook(() => useChannelContext(), {
wrapper: createWrapper(false),
})
expect(result.current.status).toBe('disconnected')
})
it('should update status when connection status changes', async () => {
const { result } = renderHook(() => useChannelStatus(), {
wrapper: createWrapper(true),
})
// Should start connecting then become connected
await waitFor(() => {
expect(result.current).toBe('connected')
}, { timeout: 5000 })
})
it('should disconnect on unmount', async () => {
const { result, unmount } = renderHook(() => useChannelContext(), {
wrapper: createWrapper(true),
})
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
// Unmount
unmount()
// Connection should be disconnected
expect(connection.status).toBe('disconnected')
})
})
})
describeIntegration('useChannelStatus (integration)', () => {
describe('ChannelProvider', () => {
let server: ChannelTestServer
let connection: ChannelConnection
beforeAll(async () => {
server = await startChannelServer()
})
afterAll(async () => {
await server.close()
})
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
connection = new ChannelConnection({ url: server.url, reconnect: false })
})
afterEach(() => {
connection.disconnect()
})
const createWrapper = (autoConnect: boolean) => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={autoConnect}>
{children}
</ChannelProvider>
)
}
}
it('should throw when useChannelContext is called outside a provider', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
it('should return current connection status', () => {
expect(() => {
renderHook(() => useChannelContext())
}).toThrow('useChannelContext must be used within a ChannelProvider')
consoleSpy.mockRestore()
})
it('should expose the provided connection and its current status', () => {
const { result } = renderHook(() => useChannelContext(), {
wrapper: wrapperFor(connection, false),
})
expect(result.current.connection).toBe(connection)
expect(result.current.status).toBe('disconnected')
})
it('should reach connected when autoConnect is true', async () => {
const { result } = renderHook(() => useChannelContext(), {
wrapper: wrapperFor(connection, true),
})
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
})
it('should leave the socket closed when autoConnect is false', () => {
const { result } = renderHook(() => useChannelContext(), {
wrapper: wrapperFor(connection, false),
})
expect(result.current.status).toBe('disconnected')
expect(connection.status).toBe('disconnected')
})
it('should disconnect the connection on unmount', async () => {
const { result, unmount } = renderHook(() => useChannelContext(), {
wrapper: wrapperFor(connection, true),
})
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
unmount()
expect(connection.status).toBe('disconnected')
})
})
describe('useChannelStatus', () => {
let server: ChannelTestServer
let connection: ChannelConnection
beforeAll(async () => {
server = await startChannelServer()
})
afterAll(async () => {
await server.close()
})
beforeEach(() => {
connection = new ChannelConnection({ url: server.url, reconnect: false })
})
afterEach(() => {
connection.disconnect()
})
it('should report disconnected without autoConnect', () => {
const { result } = renderHook(() => useChannelStatus(), {
wrapper: createWrapper(false),
wrapper: wrapperFor(connection, false),
})
expect(result.current).toBe('disconnected')
})
it('should track status through connection lifecycle', async () => {
it('should track the connection through connect and disconnect', async () => {
const { result } = renderHook(() => useChannelStatus(), {
wrapper: createWrapper(true),
wrapper: wrapperFor(connection, true),
})
// Wait for connected
await waitFor(() => {
expect(result.current).toBe('connected')
}, { timeout: 5000 })
// Disconnect manually
act(() => {
connection.disconnect()
})
// Should become disconnected
await waitFor(() => {
expect(result.current).toBe('disconnected')
})

View File

@@ -1,25 +1,36 @@
/**
* Integration tests for channel hooks
*
* These tests call the REAL backend - no mocks.
* Backend must be running: docker-compose up
*
* Run with: RUN_INTEGRATION_TESTS=true npm run test
*/
import { renderHook, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import { ChannelProvider } from '../context'
import { useChannel, useChannelLatest, useRPC } from '../hooks'
import { ChannelConnection } from '../connection'
import { startChannelServer, type ChannelTestServer } from './channelServer'
import { describeIntegration, WS_URL } from '../../testing'
describeIntegration('useChannel (integration)', () => {
function wrapperFor(connection: ChannelConnection) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={true}>
{children}
</ChannelProvider>
)
}
}
describe('useChannel', () => {
let server: ChannelTestServer
let connection: ChannelConnection
beforeAll(async () => {
server = await startChannelServer()
})
afterAll(async () => {
await server.close()
})
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
url: server.url,
reconnect: false,
})
})
@@ -28,45 +39,102 @@ describeIntegration('useChannel (integration)', () => {
connection.disconnect()
})
const createWrapper = () => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={true}>
{children}
</ChannelProvider>
)
}
}
it('should subscribe to channel when connection is ready', async () => {
const { result } = renderHook(
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'test' }),
{ wrapper: wrapperFor(connection) }
)
describe('subscription', () => {
it('should subscribe to channel when connection is ready', async () => {
const { result } = renderHook(
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'test' }),
{ wrapper: createWrapper() }
)
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
// Wait for connection to establish
await waitFor(() => {
// Status should progress from connecting
expect(['connecting', 'connected', 'subscribed']).toContain(result.current.status)
}, { timeout: 5000 })
expect(typeof result.current.send).toBe('function')
expect(typeof result.current.clearMessages).toBe('function')
expect(typeof result.current.unsubscribe).toBe('function')
expect(Array.isArray(result.current.messages)).toBe(true)
})
// Should have expected API
expect(typeof result.current.send).toBe('function')
expect(typeof result.current.clearMessages).toBe('function')
expect(typeof result.current.unsubscribe).toBe('function')
expect(Array.isArray(result.current.messages)).toBe(true)
})
it('should accumulate every message the channel echoes back', async () => {
const { result } = renderHook(
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'accumulate' }),
{ wrapper: wrapperFor(connection) }
)
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
result.current.send({ text: 'one' })
result.current.send({ text: 'two' })
await waitFor(() => {
expect(result.current.messages).toEqual([{ text: 'one' }, { text: 'two' }])
}, { timeout: 5000 })
result.current.clearMessages()
await waitFor(() => {
expect(result.current.messages).toEqual([])
}, { timeout: 5000 })
})
it('should drop the oldest message once maxMessages is exceeded', async () => {
const { result } = renderHook(
() => useChannel<{ room: string }, { text: string }, { text: string }>(
'chat',
{ room: 'bounded' },
{ maxMessages: 2 },
),
{ wrapper: wrapperFor(connection) }
)
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
result.current.send({ text: 'one' })
result.current.send({ text: 'two' })
result.current.send({ text: 'three' })
await waitFor(() => {
expect(result.current.messages).toEqual([{ text: 'two' }, { text: 'three' }])
}, { timeout: 5000 })
})
it('should stop reporting connected after unsubscribe is acknowledged', async () => {
const { result } = renderHook(
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'leave' }),
{ wrapper: wrapperFor(connection) }
)
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
result.current.unsubscribe()
await waitFor(() => {
expect(result.current.status).toBe('connecting')
}, { timeout: 5000 })
})
})
describeIntegration('useChannelLatest (integration)', () => {
describe('useChannelLatest', () => {
let server: ChannelTestServer
let connection: ChannelConnection
beforeAll(async () => {
server = await startChannelServer()
})
afterAll(async () => {
await server.close()
})
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
url: server.url,
reconnect: false,
})
})
@@ -75,16 +143,44 @@ describeIntegration('useChannelLatest (integration)', () => {
connection.disconnect()
})
const createWrapper = () => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={true}>
{children}
</ChannelProvider>
)
}
}
it('should expose latest instead of a message history', async () => {
const { result } = renderHook(
() => useChannelLatest<{ room: string }, { text: string }, { text: string }>('chat', { room: 'latest' }),
{ wrapper: wrapperFor(connection) }
)
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
expect(result.current.latest).toBeNull()
expect(result.current).not.toHaveProperty('messages')
expect(typeof result.current.send).toBe('function')
expect(typeof result.current.unsubscribe).toBe('function')
})
it('should replace latest with each newly received message', async () => {
const { result } = renderHook(
() => useChannelLatest<{ room: string }, { text: string }, { text: string }>('chat', { room: 'latest-replace' }),
{ wrapper: wrapperFor(connection) }
)
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
result.current.send({ text: 'first' })
await waitFor(() => {
expect(result.current.latest).toEqual({ text: 'first' })
}, { timeout: 5000 })
result.current.send({ text: 'second' })
await waitFor(() => {
expect(result.current.latest).toEqual({ text: 'second' })
}, { timeout: 5000 })
})
})
describeIntegration('useRPC (integration)', () => {
@@ -101,34 +197,21 @@ describeIntegration('useRPC (integration)', () => {
connection.disconnect()
})
const createWrapper = () => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={true}>
{children}
</ChannelProvider>
)
}
}
it('should track connection status', async () => {
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
const { result } = renderHook(() => useRPC(), { wrapper: wrapperFor(connection) })
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
})
it('should call backend echo function via RPC', async () => {
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
const { result } = renderHook(() => useRPC(), { wrapper: wrapperFor(connection) })
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
// Call echo function
const response = await result.current.call<{ text: string }, { message: string }>(
'echo',
{ text: 'rpc test' }
@@ -139,14 +222,12 @@ describeIntegration('useRPC (integration)', () => {
})
it('should call backend add function via RPC', async () => {
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
const { result } = renderHook(() => useRPC(), { wrapper: wrapperFor(connection) })
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
// Call add function
const response = await result.current.call<{ a: number; b: number }, { result: number }>(
'add',
{ a: 7, b: 8 }
@@ -155,4 +236,3 @@ describeIntegration('useRPC (integration)', () => {
expect(response).toHaveProperty('result', 15)
})
})

View File

@@ -1,11 +1,5 @@
'use client'
/**
* React hooks for mizan/channels
*
* Includes pub/sub channel hooks AND RPC hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useChannelContext } from './context'
import { RPCError } from './connection'
@@ -33,7 +27,7 @@ export interface UseChannelOptions<TServerMessage> {
}
/**
* Subscribe to a channel and receive typed messages.
* Subscribe to a channel and accumulate the messages it sends down.
*
* @param channelName - The registered channel name
* @param params - Channel parameters (if required)
@@ -42,12 +36,12 @@ export interface UseChannelOptions<TServerMessage> {
export function useChannel<
TParams = undefined,
TServerMessage = unknown,
TReactMessage = unknown,
TClientMessage = unknown,
>(
channelName: string,
params?: TParams,
options: UseChannelOptions<TServerMessage> = {},
): ChannelSubscription<TParams, TServerMessage, TReactMessage> {
): ChannelSubscription<TParams, TServerMessage, TClientMessage> {
const { connection, status: connectionStatus } = useChannelContext()
const [messages, setMessages] = useState<TServerMessage[]>([])
@@ -63,7 +57,6 @@ export function useChannel<
const paramsRef = useRef(params)
paramsRef.current = params
// Subscribe on mount / when params change
useEffect(() => {
if (connectionStatus !== 'connected') {
return
@@ -71,30 +64,25 @@ export function useChannel<
const currentParams = paramsRef.current ?? {}
// Subscribe
connection.send({
action: 'subscribe',
channel: channelName,
params: currentParams as Record<string, unknown>,
})
// Handle incoming messages
const unsubscribeMessages = connection.onMessage((payload: IncomingPayload) => {
// Check for subscription confirmation
if ('subscribed' in payload && payload.channel === channelName) {
setSubscribed(true)
optionsRef.current.onSubscribed?.()
return
}
// Check for unsubscription confirmation
if ('unsubscribed' in payload && payload.channel === channelName) {
setSubscribed(false)
optionsRef.current.onUnsubscribed?.()
return
}
// Check for errors
if ('error' in payload) {
if (!payload.channel || payload.channel === channelName) {
optionsRef.current.onError?.(payload.error)
@@ -102,12 +90,10 @@ export function useChannel<
return
}
// Handle data messages
if ('type' in payload && 'data' in payload) {
const message = payload.data as TServerMessage
setMessages(prev => {
const next = [...prev, message]
// Trim to max messages
if (next.length > maxMessages) {
return next.slice(-maxMessages)
}
@@ -117,7 +103,6 @@ export function useChannel<
}
})
// Cleanup: unsubscribe
return () => {
unsubscribeMessages()
@@ -129,8 +114,7 @@ export function useChannel<
}
}, [connection, connectionStatus, channelName, paramsJson, maxMessages])
// Send function
const send = useCallback((message: TReactMessage) => {
const send = useCallback((message: TClientMessage) => {
if (!subscribed) {
console.warn(`[useChannel] Cannot send: not subscribed to ${channelName}`)
return
@@ -144,7 +128,6 @@ export function useChannel<
})
}, [connection, channelName, subscribed])
// Unsubscribe function
const unsubscribe = useCallback(() => {
connection.send({
action: 'unsubscribe',
@@ -153,12 +136,11 @@ export function useChannel<
})
}, [connection, channelName])
// Clear messages
const clearMessages = useCallback(() => {
setMessages([])
}, [])
// Derive status
// Subscription confirmation, not socket readiness, is what makes this channel 'connected'
const status: ConnectionStatus = !subscribed
? connectionStatus === 'connected' ? 'connecting' : connectionStatus
: 'connected'
@@ -166,27 +148,27 @@ export function useChannel<
return {
status,
messages,
send: send as ChannelSubscription<TParams, TServerMessage, TReactMessage>['send'],
send: send as ChannelSubscription<TParams, TServerMessage, TClientMessage>['send'],
unsubscribe,
clearMessages,
}
}
/**
* Get only the latest message from a channel (useful for presence, typing indicators)
* Subscribe to a channel and keep only the most recently received message.
*/
export function useChannelLatest<
TParams = undefined,
TServerMessage = unknown,
TReactMessage = unknown,
TClientMessage = unknown,
>(
channelName: string,
params?: TParams,
options: UseChannelOptions<TServerMessage> = {},
): Omit<ChannelSubscription<TParams, TServerMessage, TReactMessage>, 'messages'> & { latest: TServerMessage | null } {
): Omit<ChannelSubscription<TParams, TServerMessage, TClientMessage>, 'messages'> & { latest: TServerMessage | null } {
const [latest, setLatest] = useState<TServerMessage | null>(null)
const channel = useChannel<TParams, TServerMessage, TReactMessage>(
const channel = useChannel<TParams, TServerMessage, TClientMessage>(
channelName,
params,
{
@@ -199,7 +181,6 @@ export function useChannelLatest<
},
)
// Explicitly exclude messages to match the documented API
const { messages: _, ...rest } = channel
return {
@@ -229,15 +210,7 @@ export interface RPCClient {
}
/**
* Get an RPC client for calling server functions.
*
* Usage:
* const rpc = useRPC()
* const result = await rpc.call('update_profile', { name: 'New Name' })
*
* The generated code wraps this with typed functions:
* const { updateProfile } = useDjango()
* const result = await updateProfile({ name: 'New Name' })
* Get an RPC client bound to the provider's connection.
*/
export function useRPC(): RPCClient {
const { connection, status } = useChannelContext()
@@ -252,5 +225,4 @@ export function useRPC(): RPCClient {
}
}
// Re-export RPCError for convenience
export { RPCError }

View File

@@ -1,64 +1,9 @@
/**
* mizan/channels
*
* Real-time WebSocket communication with Django Channels.
* Type-safe bidirectional messaging.
*
* ## Setup
*
* ```tsx
* // layout.tsx
* import { ChannelProvider } from 'mizan/channels'
*
* export default function Layout({ children }) {
* return (
* <ChannelProvider>
* {children}
* </ChannelProvider>
* )
* }
* ```
*
* ## Usage
*
* ```tsx
* // Using generated hooks (recommended)
* import { useChatChannel } from '@/api/generated.channels'
*
* function Chat({ room }) {
* const chat = useChatChannel({ room })
*
* chat.status // 'connecting' | 'connected' | 'disconnected'
* chat.messages // DjangoMessage[]
* chat.send({ text: 'Hello' }) // Send ReactMessage
* }
* ```
*
* ```tsx
* // Using raw hook (for custom channels)
* import { useChannel } from 'mizan/channels'
*
* function CustomChannel() {
* const channel = useChannel<
* { room: string }, // Params
* { user: string; text: string }, // DjangoMessage
* { text: string } // ReactMessage
* >('chat', { room: 'general' })
*
* // ...
* }
* ```
*/
// Context
export { ChannelProvider, useChannelContext, useChannelStatus } from './context'
export type { ChannelProviderProps } from './context'
// Hooks
export { useChannel, useChannelLatest, useRPC, RPCError } from './hooks'
export type { UseChannelOptions, RPCClient } from './hooks'
// Connection (for advanced use)
export { ChannelConnection, getDefaultConnection } from './connection'
export type {
ChannelConnectionOptions,
@@ -68,7 +13,6 @@ export type {
RPCErrorResponse,
} from './connection'
// Types
export type {
ConnectionStatus,
ChannelSubscription,

View File

@@ -1,18 +1,14 @@
/**
* Types for mizan/channels
*/
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
export interface ChannelSubscription<TParams = unknown, TServerMessage = unknown, TReactMessage = unknown> {
export interface ChannelSubscription<TParams = unknown, TServerMessage = unknown, TClientMessage = unknown> {
/** Current connection status */
status: ConnectionStatus
/** Received messages */
/** Messages received from the server */
messages: TServerMessage[]
/** Send a message (if channel accepts ReactMessage) */
send: TReactMessage extends never ? never : (message: TReactMessage) => void
/** Send a message up to the server; absent when the channel declares no client message */
send: TClientMessage extends never ? never : (message: TClientMessage) => void
/** Unsubscribe from the channel */
unsubscribe: () => void
@@ -35,9 +31,6 @@ export interface SubscribeOptions {
onUnsubscribed?: () => void
}
/**
* Protocol messages sent over the WebSocket
*/
export interface SubscribeAction {
action: 'subscribe'
channel: string

View File

@@ -1,127 +1,62 @@
/**
* mizan/client
*
* HTTP client factories for Django backends.
* Framework-agnostic - works with vanilla JS, React, Vue, etc.
*
* ## Quick Start
*
* ### Client-Side (CSR)
* ```ts
* import { createMizanCSRClient, Auth } from 'mizan/client'
*
* // Session-based (cookies + CSRF)
* const client = createMizanCSRClient(Auth.SESSION)
*
* // JWT-based (Bearer token)
* const client = createMizanCSRClient(Auth.JWT, { getAccessToken })
*
* const user = await client.json('GET', '/api/accounts/me/')
* ```
*
* ### Server-Side (SSR)
* ```ts
* import { createMizanSSRClient } from 'mizan/client'
*
* const client = createMizanSSRClient({
* cookies: await cookies() // Next.js cookies()
* })
*
* const user = await client.json('GET', '/api/accounts/me/')
* ```
*
* ## React Hooks
*
* For React, import from `/react`:
* ```tsx
* import { useDjangoCSRClient, Auth } from 'mizan/client/react'
*
* const client = useDjangoCSRClient(Auth.SESSION)
* ```
* HTTP client factories for Mizan backends. No framework binding — the React
* hook wrapper lives in ./react.
*/
// =============================================================================
// Types
// =============================================================================
import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils'
import { type FunctionErrorResponse } from '../errors'
export type { FunctionErrorResponse } from '../errors'
export type {
BaseUser,
AuthDetails,
AuthRoutes,
JWTTokens,
JWTConfig,
JWTState,
} from './types'
/**
* Authentication strategy for client-side requests.
*/
export enum Auth {
/** Session cookies with CSRF token */
/** Session cookies with CSRF token. */
SESSION = 'session',
/** JWT Bearer token */
/** JWT Bearer token. */
JWT = 'jwt',
}
/**
* Cookie getter interface (matches Next.js cookies() return type).
*/
/** Matches the shape Next.js `cookies()` returns. */
export interface CookieGetter {
get(name: string): { name: string; value: string } | undefined
getAll(): { name: string; value: string }[]
}
/**
* Cookie configuration for SSR requests.
* Can be either a cookie getter (like Next.js cookies()) or pre-extracted values.
*/
export type SSRCookies = CookieGetter | {
/** CSRF token value */
csrf: string
/** Full cookie header string */
/** Full `name=value; …` header string. */
cookieHeader: string
}
/**
* The core HTTP client interface for Django requests.
*/
export interface MizanHTTPClient {
/**
* Make an HTTP request, returning the raw Response.
*/
request(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<Response>
/**
* Make an HTTP request, parsing the response as JSON.
* @throws {HttpError} When response is not ok
*/
/** @throws {HttpError} When the response is not ok. */
json<T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T>
}
/**
* Configuration for CSR client.
*/
export interface CSRClientConfig {
/** Base URL for the Django backend */
baseUrl?: string | (() => string)
}
/**
* Configuration for JWT-authenticated CSR client.
*/
export interface JWTClientConfig extends CSRClientConfig {
/** Async function that returns the current access token */
getAccessToken: () => Promise<string | null>
}
/**
* Configuration for SSR client.
*/
export interface SSRClientConfig {
/** Cookies for authentication forwarding */
cookies: SSRCookies
/** Internal backend URL override (defaults to http://${INTERNAL_BACKEND_HOSTNAME}:8000) */
baseUrl?: string
/** Backend URL reachable from the server process. */
baseUrl: string
}
// =============================================================================
// Errors
// =============================================================================
/**
* Details about an HTTP error.
*/
export interface HttpErrorDetails {
status: number
statusText: string
@@ -131,9 +66,6 @@ export interface HttpErrorDetails {
bodyIsHtml?: boolean
}
/**
* Error thrown when an HTTP request fails.
*/
export class HttpError extends Error {
constructor(
message: string,
@@ -144,11 +76,12 @@ export class HttpError extends Error {
}
}
// =============================================================================
// Internal Utilities
// =============================================================================
export interface FunctionSuccessResponse<T> {
result: T
invalidate?: Array<string | { context: string; params: Record<string, any> }>
}
import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils'
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse
interface RequestBuild {
request: RequestInit
@@ -202,33 +135,32 @@ async function buildHttpError(resp: Response, url: URL | string): Promise<HttpEr
details.bodyIsHtml = contentType.includes('text/html')
details.bodySnippet = text.slice(0, 500)
}
} catch {
// Ignore body parsing errors
} catch (e) {
// The HTTP failure is the thing being reported, so a body that will
// not parse degrades the detail rather than replacing the error.
console.warn(`[mizan] Could not read error body for ${urlStr}:`, e)
}
return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details)
}
// =============================================================================
// CSR Client Factory
// =============================================================================
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
return typeof (cookies as CookieGetter).get === 'function'
}
function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } {
if (isCookieGetter(cookies)) {
return {
csrf: cookies.get(getCsrfCookieName())?.value ?? '',
cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ')
}
}
return cookies
}
/**
* Create a client-side HTTP client for Django.
*
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
* @param config - Client configuration
* @returns MizanHTTPClient
*
* @example
* // Session-based
* const client = createMizanCSRClient(Auth.SESSION)
*
* @example
* // JWT-based
* const client = createMizanCSRClient(Auth.JWT, {
* getAccessToken: async () => localStorage.getItem('token')
* })
* Client-side HTTP client. Auth.SESSION attaches the CSRF header from the
* cookie; Auth.JWT attaches a Bearer token from `config.getAccessToken`.
*/
export function createMizanCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): MizanHTTPClient
export function createMizanCSRClient(auth: Auth.JWT, config: JWTClientConfig): MizanHTTPClient
@@ -253,17 +185,15 @@ export function createMizanCSRClient(
}
return {}
}
// Session auth uses CSRF
return { [getCsrfHeaderName()]: getCSRFToken() ?? '' }
}
function resolveUrl(path: string): string {
const base = getBaseUrl()
// Absolute base URL — use URL constructor
if (base.startsWith('http://') || base.startsWith('https://')) {
return new URL(path, base).toString()
}
// Relative base path is already usable by fetch in a browser
// A relative base leaves the path already resolvable by fetch in a browser.
return path
}
@@ -290,66 +220,12 @@ export function createMizanCSRClient(
}
}
// =============================================================================
// Internal Backend URL Resolution
// =============================================================================
function getInternalBackendUrl(override?: string): string {
if (override) return override
throw new Error(
'baseUrl is required for SSR client. Pass it via config.'
)
}
// =============================================================================
// SSR Client Factory
// =============================================================================
/**
* Check if cookies is a CookieGetter interface.
* Server-side HTTP client. Forwards the caller's cookies and CSRF token on
* every request and never caches the response.
*/
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
return typeof (cookies as CookieGetter).get === 'function'
}
/**
* Extract CSRF token and cookie header from SSRCookies.
*/
function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } {
if (isCookieGetter(cookies)) {
return {
csrf: cookies.get(getCsrfCookieName())?.value ?? '',
cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ')
}
}
return cookies
}
/**
* Create a server-side HTTP client for Django.
* Used in SSR contexts (Next.js server components, server actions, etc.)
*
* @param config - SSR client configuration with cookies
* @returns MizanHTTPClient
*
* @example
* // Next.js server component
* import { cookies } from 'next/headers'
*
* const client = createMizanSSRClient({ cookies: await cookies() })
*/
// Re-export auth types for non-React usage
export type {
BaseUser,
AuthDetails,
AuthRoutes,
JWTTokens,
JWTConfig,
JWTState,
} from './types'
export function createMizanSSRClient(config: SSRClientConfig): MizanHTTPClient {
const baseUrl = getInternalBackendUrl(config.baseUrl)
const baseUrl = config.baseUrl
const { csrf, cookieHeader } = extractCookies(config.cookies)
return {
@@ -424,52 +300,25 @@ export function createMizanSSRClient(config: SSRClientConfig): MizanHTTPClient {
}
}
// =============================================================================
// SSR Session Initialization
// =============================================================================
/**
* Response from the session initialization endpoint.
*/
interface SessionInitResponse {
csrfToken: string
}
/**
* Ensure a Django session exists before making SSR requests.
*
* On first visit, the user has no cookies. This function pings Django to
* establish a session and get a CSRF token, which can then be used for
* subsequent hydration requests in the same SSR request chain.
*
* Note: Browser cookie forwarding is handled by Next.js middleware, not this
* function. This function only ensures cookies exist for SSR data fetching.
*
* @param config - SSR client configuration with cookies
* @returns Object with csrf token and cookie header for use in SSR requests
*
* @example
* // In layout.tsx
* const cookieStore = await cookies()
* const session = await ensureMizanSession({ cookies: cookieStore })
* const client = createMizanSSRClient({
* cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader }
* })
* Resolve a CSRF token and cookie header for an SSR request chain, pinging
* `/session/` first when the caller arrived without cookies.
*/
export async function ensureMizanSession(config: SSRClientConfig): Promise<{
csrf: string
cookieHeader: string
}> {
const baseUrl = getInternalBackendUrl(config.baseUrl)
const { csrf: existingCsrf, cookieHeader: existingCookies } = extractCookies(config.cookies)
// If we already have a CSRF token, just return existing cookies
if (existingCsrf) {
return { csrf: existingCsrf, cookieHeader: existingCookies }
}
// No CSRF token - need to initialize session
const url = new URL('/api/mizan/session/', baseUrl)
const url = new URL('/api/mizan/session/', config.baseUrl)
const resp = await fetch(url, {
method: 'GET',
headers: {
@@ -485,10 +334,10 @@ export async function ensureMizanSession(config: SSRClientConfig): Promise<{
return { csrf: '', cookieHeader: existingCookies }
}
// Extract CSRF token from response body
const data: SessionInitResponse = await resp.json()
// Extract Set-Cookie headers to build updated cookie string for SSR chain
// Set-Cookie carries the new session; fold it into the header so the rest
// of this SSR chain sends it.
const setCookieHeaders = resp.headers.getSetCookie?.() ?? []
const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
const combinedCookies = existingCookies
@@ -500,25 +349,3 @@ export async function ensureMizanSession(config: SSRClientConfig): Promise<{
cookieHeader: combinedCookies,
}
}
// =============================================================================
// Server Function HTTP Call
// =============================================================================
// Re-export error types from the canonical location
export type { FunctionErrorResponse } from '../errors'
import { MizanError, type FunctionErrorResponse } from '../errors'
/**
* Success response from a server function
*/
export interface FunctionSuccessResponse<T> {
result: T
invalidate?: Array<string | { context: string; params: Record<string, any> }>
}
/**
* Union type for server function responses
*/
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse

View File

@@ -9,34 +9,17 @@ import {
type CSRClientConfig,
} from './index'
// Re-export everything from main entry for convenience
export * from './index'
export * from '../jwt/JWTContext'
export type * from './types'
/**
* React hook that returns a client-side Django HTTP client.
*
* For SESSION auth, creates a session-based client with CSRF handling.
* For JWT auth, automatically wires up the JWTContext from mizan/jwt.
*
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
* @param config - Optional client configuration
* @returns MizanHTTPClient
*
* @example
* // Session-based
* const client = useMizanCSRClient(Auth.SESSION)
* const user = await client.json('GET', '/api/accounts/me/')
*
* @example
* // JWT-based (requires JWTContext from mizan/jwt)
* const client = useMizanCSRClient(Auth.JWT)
* const user = await client.json('GET', '/api/accounts/me/')
* Memoized client-side Django HTTP client. Auth.SESSION yields a session client
* with CSRF handling; Auth.JWT wires the surrounding JWTContext's token getter in.
*/
export function useMizanCSRClient(auth: Auth, config?: CSRClientConfig): MizanHTTPClient {
// Always call useJWT (React hooks must be unconditional)
// Returns null when outside JWTContext
// Unconditional because React forbids conditional hook calls; useJWT
// returns null outside a JWTContext.
const jwtContext = useJWT()
return useMemo(() => {

View File

@@ -1,6 +1,4 @@
/**
* Base user type - extend this for your app's user model.
*/
/** The user fields Mizan itself reads; the index signature carries the rest. */
export interface BaseUser {
id?: number | string
email?: string
@@ -10,51 +8,39 @@ export interface BaseUser {
[key: string]: unknown
}
/**
* Auth state derived from user.
*/
/** Authorization flags derived from a BaseUser. */
export interface AuthDetails {
isAuthenticated: boolean
isStaff: boolean
isSuperuser: boolean
}
/**
* Configuration for route guards.
*/
/** Redirect targets a route guard sends unauthorized and authorized visitors to. */
export interface AuthRoutes {
login: string
authenticated: string
}
/**
* JWT token structure.
*/
export interface JWTTokens {
accessToken: string
refreshToken: string
expiresAt: number // Unix timestamp in ms
/** Unix timestamp in milliseconds. */
expiresAt: number
}
/**
* JWT endpoint configuration.
*/
export interface JWTConfig {
/** Base URL for API calls (default: '' - use relative URLs) */
/** Prefix for API calls; empty means relative URLs. */
baseUrl?: string
/** mizan server function endpoint (default: /api/mizan/call/) */
/** Mizan server-function endpoint path. */
endpoint?: string
/** Seconds before expiry to trigger refresh (default: 30) */
/** Seconds before expiry at which a refresh is triggered. */
refreshBuffer?: number
/** Auto-obtain tokens on mount (default: true) */
/** Obtain tokens on mount. */
autoObtain?: boolean
/** Auto-refresh tokens before expiry (default: true) */
/** Refresh tokens before expiry. */
autoRefresh?: boolean
}
/**
* JWT state and methods.
*/
export interface JWTState {
tokens: JWTTokens | null
isLoading: boolean

View File

@@ -1,24 +1,10 @@
'use client'
/**
* mizan React Context
* React provider for Mizan server functions and contexts.
*
* Provides server function calls via HTTP (default) or WebSocket RPC (opt-in).
* This is the core React integration for Django server functions.
*
* Transport Model:
* - HTTP-first: Functions use HTTP by default (transport='http' or undefined)
* - WebSocket opt-in: Functions with transport='websocket' use WebSocket RPC
* when connected, falling back to HTTP when disconnected
*
* Two layers:
* 1. MizanProvider (this file) - Generic provider with name-based API
* - Libraries like Allauth use this: useMizan(), useContext('current_user')
*
* 2. Generated DjangoContext (in @/api) - Typed wrapper around MizanProvider
* - Product code uses this: useCurrentUser(), useUpdateProfile()
*
* The generated code wraps MizanProvider and adds type-safe hooks.
* Transport: HTTP unless a call passes transport='websocket', in which case
* WebSocket RPC is used while connected and HTTP serves as the fallback.
*/
import {
@@ -70,13 +56,9 @@ export interface MizanContextValue {
/**
* Call a server function by name.
*
* Transport behavior:
* - 'http' (default): Always use HTTP POST /api/mizan/call/
* - 'websocket': Use WebSocket RPC when connected, HTTP fallback when not
*
* @param functionName - The server function name (e.g., 'echo', 'update_profile')
* @param functionName - The server function name
* @param input - Optional input data for the function
* @param transport - Transport mode ('http' or 'websocket', defaults to 'http')
* @param transport - 'http' (default) or 'websocket'
*/
call: <TInput = unknown, TOutput = unknown>(
functionName: string,
@@ -124,16 +106,14 @@ export interface MizanContextValue {
onContextChange: (name: string, listener: (data: unknown) => void) => () => void
/**
* Promise that resolves when the session is initialized (CSRF cookie set).
* Await this before making HTTP calls in contexts where timing matters
* (e.g., calling a server function immediately on mount).
* Resolves once the session is initialized (CSRF cookie set). An HTTP call
* issued before that resolves would be missing its CSRF header.
*/
whenReady: Promise<void>
/**
* Invalidate a named context, triggering a refetch.
* Only refetches if the context is currently mounted (has a registered provider).
* No-op if the context is not mounted.
* No-op when the context has no mounted provider.
*/
invalidateContext: (name: string) => Promise<void>
@@ -145,8 +125,7 @@ export interface MizanContextValue {
/**
* Register a named context provider for invalidation support.
* Called by generated context providers on mount.
* Returns an unregister function (call on unmount).
* Returns an unregister function to call on unmount.
*/
registerContextProvider: (
name: string,
@@ -154,20 +133,18 @@ export interface MizanContextValue {
) => () => void
/**
* Base URL for HTTP calls (for use by generated context providers).
* Base URL for HTTP calls.
*/
baseUrl: string
/**
* Set context data directly without triggering a network request.
* Used by generated providers that fetch bundled responses.
*/
setContextData: (name: string, data: unknown) => void
/**
* Make an authenticated HTTP request.
* Handles JWT Bearer or session cookie auth automatically.
* Waits for session init before making the request.
* Make an authenticated HTTP request. Resolves JWT Bearer or session
* cookie auth and waits for session init before issuing it.
*/
request: (method: string, path: string, data?: unknown) => Promise<Response>
}
@@ -182,8 +159,7 @@ export interface MizanProviderProps {
hydration?: MizanHydration
/**
* List of context names to auto-fetch if not in hydration.
* These will be fetched on mount.
* Context names to fetch on mount when absent from hydration.
*/
contexts?: string[]
@@ -213,7 +189,7 @@ export interface MizanProviderProps {
maxReconnectAttempts?: number
/**
* Custom connection instance (for testing).
* Connection instance to use instead of constructing one.
*/
connection?: ChannelConnection
}
@@ -248,19 +224,15 @@ export function MizanProvider({
// Context change listeners: Map<name, Set<listener>>
const contextListenersRef = useRef<Map<string, Set<(data: unknown) => void>>>(new Map())
// Context data store
const [contextStore, setContextStore] = useState<ContextStore>(() => {
// Initialize from hydration if provided
return hydration ?? {}
})
// Check if JWT is available - use JWT auth if so, otherwise session auth
// JWT presence selects the auth method: Bearer when available, session otherwise.
const jwt = useJWT()
const hasJWT = jwt !== null && jwt.tokens !== null
const [sessionReady, setSessionReady] = useState(false)
// Promise that resolves when session is initialized.
// Exposed via context so any code that needs to wait for CSRF can await it.
const sessionRef = useRef<{ promise: Promise<void>; resolve: () => void } | null>(null)
if (!sessionRef.current) {
let resolve!: () => void
@@ -268,7 +240,6 @@ export function MizanProvider({
sessionRef.current = { promise, resolve }
}
// Create HTTP client with appropriate auth method
const httpClient = useMemo(() => {
if (jwt?.getAccessToken) {
return createMizanCSRClient(Auth.JWT, {
@@ -279,7 +250,6 @@ export function MizanProvider({
return createMizanCSRClient(Auth.SESSION, { baseUrl })
}, [hasJWT, jwt?.getAccessToken, baseUrl])
// Create or use provided connection
if (!connectionRef.current) {
connectionRef.current = providedConnection ?? new ChannelConnection({
url: wsUrl,
@@ -291,24 +261,23 @@ export function MizanProvider({
const connection = connectionRef.current
// Track connection status
const [status, setStatus] = useState<ConnectionStatus>(
connection.status as ConnectionStatus
)
// The core call function: HTTP-first, WebSocket opt-in
const call = useCallback(
async <TInput = unknown, TOutput = unknown>(
functionName: string,
input?: TInput,
transport: Transport = 'http'
): Promise<TOutput> => {
// Only attempt WebSocket if explicitly requested AND connected
if (transport === 'websocket' && connection.status === 'connected') {
try {
return await connection.rpc<TInput, TOutput>(functionName, input as TInput)
} catch (e) {
// If it's an RPC error (function error), re-throw as MizanError
// An RPCError is the function's own failure, so it converts
// to MizanError; anything else is a connection fault and
// falls through to the HTTP path below.
if (e instanceof RPCError) {
throw new MizanError({
error: true,
@@ -318,7 +287,6 @@ export function MizanProvider({
})
}
// Connection error - fall through to HTTP
console.warn(
`[mizan] WebSocket RPC failed for '${functionName}', falling back to HTTP:`,
e
@@ -326,7 +294,6 @@ export function MizanProvider({
}
}
// Wait for session init (CSRF cookie) before making HTTP requests
await sessionRef.current!.promise
const response = await httpClient.request(
@@ -341,7 +308,6 @@ export function MizanProvider({
throw new MizanError(data as FunctionErrorResponse)
}
// Server-driven invalidation: process the invalidate array
if (data.invalidate && Array.isArray(data.invalidate)) {
for (const entry of data.invalidate) {
if (typeof entry === 'string') {
@@ -359,7 +325,6 @@ export function MizanProvider({
[connection, baseUrl, httpClient]
)
// Get cached context data
const getContext = useCallback(
<T = unknown>(name: string): T | undefined => {
return contextStore[name] as T | undefined
@@ -367,7 +332,6 @@ export function MizanProvider({
[contextStore]
)
// Refresh a specific context via GET /ctx/<name>/
const refreshContext = useCallback(
async (name: string): Promise<void> => {
try {
@@ -375,7 +339,6 @@ export function MizanProvider({
const data = await response.json()
setContextStore(prev => {
const next = { ...prev, [name]: data }
// Notify listeners
const listeners = contextListenersRef.current.get(name)
if (listeners) {
listeners.forEach(listener => {
@@ -396,7 +359,6 @@ export function MizanProvider({
[call]
)
// Refresh all registered contexts
const refreshAllContexts = useCallback(
async (): Promise<void> => {
await Promise.all(contextNames.map(name => refreshContext(name)))
@@ -404,7 +366,6 @@ export function MizanProvider({
[contextNames, refreshContext]
)
// Subscribe to context changes
const onContextChange = useCallback(
(name: string, listener: (data: unknown) => void): (() => void) => {
const listeners = contextListenersRef.current.get(name) ?? new Set()
@@ -424,7 +385,6 @@ export function MizanProvider({
[]
)
// Subscribe to push messages
const onPush = useCallback(
<T = unknown>(topic: string, listener: PushListener<T>): (() => void) => {
const listeners = pushListenersRef.current.get(topic) ?? new Set()
@@ -444,13 +404,11 @@ export function MizanProvider({
[]
)
// Connect on mount and listen for push messages
useEffect(() => {
const unsubscribeStatus = connection.onStatusChange((newStatus) => {
setStatus(newStatus as ConnectionStatus)
})
// Listen for all messages (including push)
const unsubscribeMessages = connection.onMessage((payload) => {
if (payload && typeof payload === 'object' && 'type' in payload && payload.type === 'push') {
const topic = (payload as { topic?: string }).topic
@@ -482,7 +440,8 @@ export function MizanProvider({
}
}, [connection, autoConnect])
// Session init for CSR (fallback if proxy didn't run)
// A JWT or an existing CSRF cookie means the session already exists;
// otherwise hit /session/ to have the backend set one.
useEffect(() => {
if (hasJWT || getCSRFToken()) {
setSessionReady(true)
@@ -497,7 +456,6 @@ export function MizanProvider({
})
}, [hasJWT, baseUrl])
// Auto-fetch contexts that weren't hydrated
useEffect(() => {
if (!sessionReady) return
if (!hydration) {
@@ -513,7 +471,6 @@ export function MizanProvider({
const isRPCAvailable = status === 'connected'
// Named context provider registry for invalidation
const contextProvidersRef = useRef<Map<string, { refetch: () => Promise<void> }>>(new Map())
const registerContextProvider = useCallback(
@@ -532,16 +489,15 @@ export function MizanProvider({
if (provider) {
await provider.refetch()
}
// If not mounted, no-op — no wasted request
// Unmounted context: nothing renders it, so no request is worth issuing.
},
[]
)
const invalidateFunctions = useCallback(
async (names: string[]): Promise<void> => {
// Function names are passed directly as context invalidation targets.
// The server already resolved function → context mapping.
// Dedupe and invalidate each.
// The server already resolved function → context, so these names
// are usable directly as invalidation targets.
const contexts = new Set(names)
await Promise.all(
Array.from(contexts).map(ctx => invalidateContext(ctx))
@@ -550,7 +506,6 @@ export function MizanProvider({
[invalidateContext]
)
// Set context data directly (used by generated providers that fetch bundles)
const setContextData = useCallback(
(name: string, data: unknown) => {
setContextStore(prev => {
@@ -571,7 +526,6 @@ export function MizanProvider({
[]
)
// Auth-transparent HTTP request (used by generated context providers)
const request = useCallback(
async (method: string, path: string, data?: unknown): Promise<Response> => {
await sessionRef.current!.promise
@@ -613,21 +567,7 @@ export function MizanProvider({
// ============================================================================
/**
* Access the mizan context.
*
* Provides generic name-based API for server functions and contexts.
* Libraries should use this hook, not the typed generated hooks.
*
* @example
* ```tsx
* // Library code (e.g., Allauth)
* import { useMizan } from 'mizan'
*
* function useUser() {
* const { getContext } = useMizan()
* return getContext('current_user')
* }
* ```
* Access the Mizan context value. Throws outside a MizanProvider.
*/
export function useMizan(): MizanContextValue {
const context = useReactContext(MizanContextInternal)
@@ -638,17 +578,7 @@ export function useMizan(): MizanContextValue {
}
/**
* Get cached context data by name.
*
* For use by libraries that need to access context data without knowing types.
*
* @example
* ```tsx
* // In Allauth library
* function useUser() {
* return useMizanContext('current_user')
* }
* ```
* Cached data for a named context, or undefined before it has loaded.
*/
export function useMizanContext<T = unknown>(name: string): T | undefined {
const { getContext } = useMizan()
@@ -656,26 +586,10 @@ export function useMizanContext<T = unknown>(name: string): T | undefined {
}
/**
* Get a function caller by name with transport control.
*
* For use by libraries that need to call functions without knowing types.
* The transport parameter is baked into the returned function.
* A caller bound to one function name and transport.
*
* @param functionName - The server function name
* @param transport - Transport mode ('http' or 'websocket', defaults to 'http')
*
* @example
* ```tsx
* // HTTP-only function (default)
* function useUpdateProfile() {
* return useMizanCall('update_profile')
* }
*
* // WebSocket-enabled function
* function useSendMessage() {
* return useMizanCall('send_message', 'websocket')
* }
* ```
* @param transport - 'http' (default) or 'websocket'
*/
export function useMizanCall<TInput = unknown, TOutput = unknown>(
functionName: string,
@@ -689,7 +603,7 @@ export function useMizanCall<TInput = unknown, TOutput = unknown>(
}
/**
* Get the current WebSocket connection status.
* The current WebSocket connection status.
*/
export function useMizanStatus(): ConnectionStatus {
const { status } = useMizan()
@@ -697,8 +611,7 @@ export function useMizanStatus(): ConnectionStatus {
}
/**
* Subscribe to push messages for a topic.
* Automatically unsubscribes when the component unmounts.
* Subscribe to push messages for a topic, unsubscribing on unmount.
*/
export function usePush<T = unknown>(
topic: string,
@@ -711,6 +624,8 @@ export function usePush<T = unknown>(
callbackRef.current = callback
}, [callback])
// The ref indirection keeps the subscription stable when the caller passes
// a fresh closure on every render.
useEffect(() => {
const listener: PushListener<T> = (message) => {
callbackRef.current(message)
@@ -719,31 +634,3 @@ export function usePush<T = unknown>(
return onPush(topic, listener)
}, [topic, onPush])
}
// ============================================================================
// Legacy Aliases (for backwards compatibility during migration)
// ============================================================================
/** @deprecated Use MizanProvider instead */
export const DjangoContext = MizanProvider
/** @deprecated Use useMizan instead */
export const useDjango = useMizan
/** @deprecated Use useMizanStatus instead */
export const useDjangoStatus = useMizanStatus
/** @deprecated Use useMizanCall instead */
export function useServerFunction<TInput = unknown, TOutput = unknown>(
functionName: string
): (input: TInput) => Promise<TOutput> {
const { call } = useMizan()
return useCallback(
(input: TInput) => call<TInput, TOutput>(functionName, input),
[call, functionName]
)
}
// Re-export types for the legacy API
export type DjangoContextValue = MizanContextValue
export type DjangoContextProps = MizanProviderProps

View File

@@ -1,18 +1,8 @@
'use client'
/**
* mizan Forms - Typed React Form Hooks for Django Server Functions
*
* This module provides the core form state management that generated
* form hooks use. It integrates with mizan server functions for
* schema fetching, validation, and submission.
*
* Users don't use this directly - they use generated typed hooks:
*
* import { useContactForm } from '@/api/forms'
* const form = useContactForm()
* form.data.email // typed!
* form.set('email', 'x') // typed!
* Form state for Mizan server-backed forms: schema fetch, Zod + server
* validation, and submit.
*/
import {
@@ -26,8 +16,9 @@ import type { ZodObject, ZodRawShape, ZodError } from 'zod'
import { useMizan } from './context'
import { MizanError } from './errors'
// Forms always use HTTP transport because Django Allauth and other auth
// systems require full HTTP request semantics (session, cookies, CSRF).
// Every call below pins transport to 'http': Django Allauth and the other
// auth flows need full HTTP request semantics (session, cookies, CSRF), which
// the WebSocket path does not carry.
// ============================================================================
// Types
@@ -121,7 +112,7 @@ export interface FormOptions {
/**
* Whether to refetch the schema on each validation.
* Useful for forms with dynamic choice fields.
* Needed when a form carries dynamic choice fields.
* @default false (uses schema.meta value)
*/
refetchSchemaOnValidate?: boolean
@@ -131,9 +122,6 @@ export interface FormOptions {
* - 'on-submit': Only validate on server during submit (default)
* - 'live': Also run debounced server validation on field touch
*
* Use 'live' for forms with server-only validation rules
* (e.g., uniqueness checks, DB lookups).
*
* @default 'on-submit'
*/
serverValidation?: 'on-submit' | 'live'
@@ -184,7 +172,7 @@ export interface MizanFormState<TData extends Record<string, unknown>> {
/** Get form-level errors (non-field errors) */
getFormErrors: () => FieldError[]
/** Set a field value - typed! */
/** Set a field value */
set: <K extends keyof TData>(field: K, value: TData[K]) => void
/** Mark a field as touched (triggers validation) */
@@ -278,11 +266,9 @@ function transformValidation<TData extends Record<string, unknown>>(
for (const fieldErrors of raw.errors) {
if (fieldErrors.field === '__all__') {
// Tag form-level errors with server source
form.push(...fieldErrors.errors.map(e => ({ ...e, source: 'server' as const })))
} else {
const name = fieldErrors.field as keyof TData
// Tag field errors with server source
fields[name] = fieldErrors.errors.map(e => ({ ...e, source: 'server' as const }))
}
}
@@ -307,7 +293,7 @@ function initializeData<TData extends Record<string, unknown>>(
}
/**
* Clean invalid choice values when schema changes.
* Blank out any choice-field value that the current schema no longer offers.
*/
function cleanInvalidChoices<TData extends Record<string, unknown>>(
data: TData,
@@ -388,10 +374,10 @@ export interface MizanFormsetState<TData extends Record<string, unknown>> {
/** Whether a form can be removed */
canRemoveForm: boolean
/** Get a field value - typed! */
/** Get a field value */
get: <K extends keyof TData>(formIndex: number, field: K) => TData[K] | undefined
/** Set a field value - typed! */
/** Set a field value */
set: <K extends keyof TData>(formIndex: number, field: K, value: TData[K]) => void
/** Get field schema */
@@ -422,17 +408,12 @@ export interface MizanFormsetState<TData extends Record<string, unknown>> {
/**
* Configuration for useMizanFormCore.
* This is used by generated hooks - not directly by users.
*/
export interface FormCoreConfig<TData extends Record<string, unknown>> {
/** Form name (used for server function calls: name.schema, name.validate, name.submit) */
name: string
/**
* Zod schema for client-side validation.
* Generated from Django form field definitions.
* Provides instant validation feedback without server round-trip.
*/
/** Zod schema for client-side validation, checked before any server round-trip. */
zodSchema?: ZodObject<ZodRawShape>
/** Form options */
@@ -440,12 +421,8 @@ export interface FormCoreConfig<TData extends Record<string, unknown>> {
}
/**
* Core form hook that generated hooks use internally.
*
* This is NOT meant to be used directly by users.
* Use generated typed hooks instead (e.g., useContactForm).
*
* @internal
* Form state machine: loads the schema, tracks data and touched fields, runs
* Zod then server validation, and submits.
*/
export function useMizanFormCore<TData extends Record<string, unknown>>(
config: FormCoreConfig<TData>
@@ -483,7 +460,6 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
return { fields: {} as { [K in keyof TData]?: FieldError[] }, form: [] }
}
// Convert Zod errors to our format with source tag
const fields = {} as { [K in keyof TData]?: FieldError[] }
for (const issue of result.error.issues) {
const fieldName = issue.path[0] as keyof TData
@@ -529,7 +505,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
const rawSchema = await call<{ data?: Record<string, unknown> }, RawFormSchema>(
`${name}.schema`,
{ data: {} },
'http' // Forms always use HTTP
'http'
)
if (cancelled) return
@@ -562,44 +538,37 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
}
}, [])
// Trigger validation
// Strategy: Zod first (instant), then optionally server (for complex rules)
// Zod runs first for instant feedback; the server pass only follows when
// Zod is clean, since it exists to catch rules Zod cannot express.
const triggerValidation = useCallback(async (zodOnly: boolean = false) => {
if (!schema) return
const seq = ++validationSeqRef.current
// Step 1: Instant Zod validation
const zodErrors = validateWithZod(data)
if (zodErrors) {
setErrors(zodErrors)
setPendingFields(new Set())
// If zodOnly mode, stop here (instant feedback only)
if (zodOnly) {
return
}
// If Zod found errors, skip server validation - no point
// Server validation is for catching server-only rules (uniqueness, DB lookups)
// when client-side validation passes
if (Object.keys(zodErrors.fields).length > 0) {
return
}
}
// Step 2: Server validation (for complex rules like uniqueness, DB lookups)
setValidating(true)
try {
let currentSchema = schema
let currentData = data
// Refetch schema if needed
if (shouldRefetchSchema()) {
const rawSchema = await call<{ data: TData }, RawFormSchema>(
`${name}.schema`,
{ data },
'http' // Forms always use HTTP
'http'
)
if (seq !== validationSeqRef.current) return
@@ -607,35 +576,31 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
currentSchema = transformSchema<TData>(rawSchema)
setSchema(currentSchema)
// Clean invalid choice values
currentData = cleanInvalidChoices(data, currentSchema)
if (JSON.stringify(currentData) !== JSON.stringify(data)) {
setData(currentData)
}
}
// Validate with server
const rawValidation = await call<{ data: TData }, RawFormValidation>(
`${name}.validate`,
{ data: currentData },
'http' // Forms always use HTTP
'http'
)
if (seq !== validationSeqRef.current) return
let serverErrors = transformValidation<TData>(rawValidation)
// Filter out form-level errors if not showing them
if (!shouldShowFormErrors()) {
serverErrors = { ...serverErrors, form: [] }
}
// In hybrid mode, merge Zod and server errors
// Server errors take precedence per-field (more authoritative)
// In hybrid mode the server's per-field verdict overrides Zod's,
// being the more authoritative of the two.
if (zodErrors && serverValidationMode === 'live') {
const mergedFields = { ...zodErrors.fields } as { [K in keyof TData]?: FieldError[] }
// Server errors override Zod errors per-field
for (const [field, errors] of Object.entries(serverErrors.fields)) {
if (errors && errors.length > 0) {
mergedFields[field as keyof TData] = errors
@@ -652,8 +617,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
setPendingFields(new Set())
} catch (err) {
// Keep whatever Zod already reported; the server pass is additive.
console.debug('Validation error:', err)
// On server error, keep Zod errors if any
} finally {
if (seq === validationSeqRef.current) {
setValidating(false)
@@ -672,11 +637,9 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
if (!shouldLiveValidate()) return
// Instant Zod validation (no debounce needed)
if (zodSchema) {
triggerValidation(true) // zodOnly = true - instant feedback
triggerValidation(true)
// If hybrid mode enabled, also schedule debounced server validation
if (serverValidationMode === 'live') {
const timeouts = touchTimeoutsRef.current
const existing = timeouts.get(field)
@@ -684,7 +647,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
const id = window.setTimeout(() => {
timeouts.delete(field)
triggerValidation(false) // Full validation including server
triggerValidation(false)
}, debounceMs)
timeouts.set(field, id)
@@ -692,7 +655,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
return
}
// Server-only validation (no Zod schema) - use pending state
// No Zod schema: the field stays "pending" so no stale error is shown
// while the debounced server round-trip is in flight.
setPendingFields(prev => new Set(prev).add(field))
const timeouts = touchTimeoutsRef.current
@@ -703,7 +667,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
const id = window.setTimeout(() => {
timeouts.delete(field)
triggerValidation(false) // Full validation including server
triggerValidation(false)
}, debounceMs)
timeouts.set(field, id)
@@ -714,17 +678,14 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
field: keyof TData,
options?: { source?: ErrorSource }
): FieldError[] => {
// Don't show errors for fields that haven't been touched yet
if (!touchedFields.has(field as keyof TData & string)) {
return []
}
// Don't show errors for fields with pending server validation
if (pendingFields.has(field as keyof TData & string)) {
return []
}
const fieldErrors = errors?.fields[field] ?? []
// Filter by source if requested
if (options?.source) {
return fieldErrors.filter(e => e.source === options.source)
}
@@ -757,7 +718,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
const response = await call<TData, FormSubmitPassResponse | FormSubmitFailResponse>(
`${name}.submit`,
data,
'http' // Forms always use HTTP
'http'
)
if (response.success) {
@@ -768,7 +729,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
return { success: false, errors: typedErrors }
}
} catch (err) {
// Handle MizanError with validation details
// A validation MizanError carries field errors, so it becomes a
// failed result rather than a thrown exception.
if (err instanceof MizanError && err.isValidationError()) {
const rawFieldErrors = err.getFieldErrors()
const fields = {} as { [K in keyof TData]?: FieldError[] }
@@ -797,11 +759,9 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
}
}, [name, data, call])
// Computed properties
// Only consider errors for touched fields (consistent with getFieldErrors)
// Only touched fields count, to stay consistent with getFieldErrors.
const hasErrors = useMemo(() => {
if (!errors) return false
// Check for field errors only in touched fields
const hasFieldErrors = Array.from(touchedFields).some(field => {
const fieldErrors = errors.fields[field as keyof TData]
return fieldErrors && fieldErrors.length > 0
@@ -812,7 +772,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
const isValid = useMemo(() => {
if (errors === null || hasErrors) return false
// Also check that all required fields have been touched
// An untouched required field has not been validated yet, so the form
// cannot be called valid.
if (schema) {
for (const fieldName of schema.fieldOrder) {
const field = schema.fields[fieldName]
@@ -879,10 +840,7 @@ export interface FormsetCoreConfig<TData extends Record<string, unknown>> {
/** Form name (used for server function calls) */
name: string
/**
* Zod schema for client-side validation of individual forms.
* Generated from Django form field definitions.
*/
/** Zod schema for client-side validation of each individual form. */
zodSchema?: ZodObject<ZodRawShape>
/** Initial number of forms */
@@ -896,9 +854,8 @@ export interface FormsetCoreConfig<TData extends Record<string, unknown>> {
}
/**
* Core formset hook that generated hooks use internally.
*
* @internal
* Formset state machine: a variable-length list of forms sharing one schema
* fetch, one validation pass, and one submit.
*/
export function useMizanFormsetCore<TData extends Record<string, unknown>>(
config: FormsetCoreConfig<TData>
@@ -937,7 +894,7 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
const rawSchema = await call<{ forms: TData[] }, RawFormsetSchema>(
`${name}.formset.schema`,
{ forms: formsData },
'http' // Forms always use HTTP
'http'
)
setSchemas(rawSchema.forms.map(raw => transformSchema<TData>(raw)))
@@ -967,9 +924,9 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
return () => {
cancelled = true
}
}, []) // Only on mount
}, [])
// Update schema when form count changes
// The backend returns one schema per form, so the count drives a refetch.
useEffect(() => {
if (!loading) {
updateSchema(forms)
@@ -993,7 +950,7 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
const rawValidation = await call<{ forms: TData[] }, RawFormsetValidation>(
`${name}.formset.validate`,
{ forms },
'http' // Forms always use HTTP
'http'
)
if (seq !== validationSeqRef.current) return
@@ -1024,11 +981,9 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
return updated
})
// Also touch the field
const key = `${formIndex}-${String(field)}`
setTouchedFields(prev => new Set(prev).add(key))
// Debounced validation
const timeouts = touchTimeoutsRef.current
const existing = timeouts.get(key)
if (existing) clearTimeout(existing)
@@ -1096,15 +1051,14 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
validationSeqRef.current++
try {
// Check for files
const hasFiles = forms.some(form =>
Object.values(form).some(value => value instanceof File)
)
if (hasFiles) {
// Use HTTP with FormData for file uploads
// Server functions don't support multipart yet
throw new Error('File uploads in formsets require HTTP transport (not yet implemented)')
// The server-function wire format is JSON; a File needs
// multipart, which this path does not construct.
throw new Error('File uploads in formsets require a multipart HTTP transport')
}
const response = await call<
@@ -1113,7 +1067,7 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
>(
`${name}.formset.submit`,
{ forms },
'http' // Forms always use HTTP
'http'
)
if (response.success) {

View File

@@ -1,20 +1,5 @@
/**
* mizan — Server Functions Client
*
* Frontend client for Mizan server functions.
* Server functions are the core primitive — accessed via React hooks.
*
* Two-layer architecture:
*
* 1. Library layer (this package) — Generic name-based API
* import { useMizan, useMizanContext, useMizanCall } from 'mizan'
*
* 2. Generated layer (@/api) — Typed project-specific API
* import { useCurrentUser, useUpdateProfile } from '@/api'
*/
// ============================================================================
// React Context & Hooks (primary API)
// React Context & Hooks
// ============================================================================
export {
@@ -93,34 +78,3 @@ export {
// ============================================================================
export { configureCsrf } from './utils'
// ============================================================================
// Legacy aliases (deprecated)
// ============================================================================
export {
// Provider aliases
DjangoContext,
useDjango,
useDjangoStatus,
useServerFunction,
type DjangoContextValue,
type DjangoContextProps,
} from './context'
export {
// Client aliases
createMizanCSRClient as createDjangoCSRClient,
createMizanSSRClient as createDjangoSSRClient,
ensureMizanSession as ensureDjangoSession,
type MizanHTTPClient as DjangoHTTPClient,
} from './client/'
export { MizanError as DjangoError } from './errors'
export {
useMizanFormCore as useDjangoFormCore,
type MizanFormState as DjangoFormState,
useMizanFormsetCore as useDjangoFormsetCore,
type MizanFormsetState as DjangoFormsetState,
} from './forms'

View File

@@ -27,6 +27,10 @@ interface JWTContextProps {
config?: JWTConfig
}
type TokenOutcome =
| { authenticated: true; tokens: JWTTokens }
| { authenticated: false }
export function JWTContext({ children, config }: JWTContextProps) {
const cfg = { ...DEFAULT_CONFIG, ...config }
@@ -34,10 +38,12 @@ export function JWTContext({ children, config }: JWTContextProps) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Helper to call server functions
const callServerFunction = useCallback(async (fn: string, args: Record<string, unknown> = {}) => {
const requestTokens = useCallback(async (
fn: string,
args: Record<string, unknown> = {},
): Promise<TokenOutcome> => {
const url = cfg.baseUrl ? `${cfg.baseUrl}${cfg.endpoint}` : cfg.endpoint
const csrfToken = getCSRFToken()
const response = await fetch(url, {
@@ -46,52 +52,52 @@ export function JWTContext({ children, config }: JWTContextProps) {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
},
credentials: 'include', // Include session cookie for CSRF
credentials: 'include', // Session cookie is what the CSRF token is checked against.
body: JSON.stringify({ fn, args }),
})
const data = await response.json()
if (data.error) {
const err = new Error(data.message || 'Server function failed')
;(err as any).code = data.code
;(err as any).details = data.details
throw err
// FORBIDDEN is how the server names an absent session or a spent
// refresh token — an unauthenticated outcome, not a failed call.
if (data.code === 'FORBIDDEN') {
return { authenticated: false }
}
const failure = new Error(data.message || 'Server function failed')
;(failure as Error & { code?: string; details?: unknown }).code = data.code
;(failure as Error & { code?: string; details?: unknown }).details = data.details
throw failure
}
return data.data
return {
authenticated: true,
tokens: {
accessToken: data.data.access_token,
refreshToken: data.data.refresh_token,
expiresAt: Date.now() + data.data.expires_in * 1000,
},
}
}, [cfg.baseUrl, cfg.endpoint])
// Obtain tokens from session
const obtainTokens = useCallback(async (): Promise<JWTTokens | null> => {
setIsLoading(true)
setError(null)
try {
const result = await callServerFunction('jwt_obtain')
const newTokens: JWTTokens = {
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: Date.now() + result.expires_in * 1000,
}
setTokens(newTokens)
return newTokens
} catch (err: any) {
// FORBIDDEN means not authenticated - expected, not an error
if (err.code === 'FORBIDDEN') {
setTokens(null)
return null
}
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
const outcome = await requestTokens('jwt_obtain')
const next = outcome.authenticated ? outcome.tokens : null
setTokens(next)
return next
} catch (err) {
const failure = err instanceof Error ? err : new Error(String(err))
setError(failure)
return null
} finally {
setIsLoading(false)
}
}, [callServerFunction])
}, [requestTokens])
// Refresh tokens
const refreshTokens = useCallback(async (): Promise<JWTTokens | null> => {
if (!tokens?.refreshToken) {
return null
@@ -101,32 +107,21 @@ export function JWTContext({ children, config }: JWTContextProps) {
setError(null)
try {
const result = await callServerFunction('jwt_refresh', {
const outcome = await requestTokens('jwt_refresh', {
refresh_token: tokens.refreshToken,
})
const newTokens: JWTTokens = {
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: Date.now() + result.expires_in * 1000,
}
setTokens(newTokens)
return newTokens
} catch (err: any) {
// FORBIDDEN means refresh token invalid/expired - clear tokens
if (err.code === 'FORBIDDEN') {
setTokens(null)
return null
}
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
const next = outcome.authenticated ? outcome.tokens : null
setTokens(next)
return next
} catch (err) {
const failure = err instanceof Error ? err : new Error(String(err))
setError(failure)
return null
} finally {
setIsLoading(false)
}
}, [tokens?.refreshToken, callServerFunction])
}, [tokens?.refreshToken, requestTokens])
// Clear tokens
const clearTokens = useCallback(() => {
setTokens(null)
setError(null)
@@ -136,13 +131,11 @@ export function JWTContext({ children, config }: JWTContextProps) {
}
}, [])
// Get access token (refresh if needed)
const getAccessToken = useCallback(async (): Promise<string | null> => {
if (!tokens) {
return null
}
// Check if token needs refresh
const bufferMs = cfg.refreshBuffer * 1000
if (tokens.expiresAt - Date.now() < bufferMs) {
const newTokens = await refreshTokens()
@@ -152,24 +145,20 @@ export function JWTContext({ children, config }: JWTContextProps) {
return tokens.accessToken
}, [tokens, cfg.refreshBuffer, refreshTokens])
// Auto-obtain on mount
useEffect(() => {
if (!cfg.autoObtain) return
obtainTokens()
}, [cfg.autoObtain, obtainTokens])
// Auto-refresh before expiry
useEffect(() => {
if (!cfg.autoRefresh || !tokens) {
return
}
// Clear existing timeout
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
}
// Schedule refresh
const bufferMs = cfg.refreshBuffer * 1000
const timeUntilRefresh = tokens.expiresAt - Date.now() - bufferMs
@@ -200,19 +189,14 @@ export function JWTContext({ children, config }: JWTContextProps) {
}
/**
* Hook to access JWT state and methods.
*
* When used outside JWTContext, returns null. This allows
* conditional JWT usage (e.g., useDjangoApi({ jwt: true }))
* without requiring JWTContext to always be present.
* JWT state and methods, or null outside JWTContext.
*/
export function useJWT(): JWTState | null {
return useContext(Context)
}
/**
* Hook to access JWT state with a guarantee it exists.
* Throws if used outside JWTContext.
* JWT state and methods. Throws outside JWTContext.
*/
export function useJWTRequired(): JWTState {
const context = useContext(Context)
@@ -222,7 +206,7 @@ export function useJWTRequired(): JWTState {
return context
}
/** Check if JWT is available (tokens obtained) */
/** True once tokens have been obtained and no request is in flight. */
export function useJWTReady(): boolean {
const jwt = useJWT()
if (!jwt) return false

View File

@@ -1,44 +1,45 @@
/**
* Integration tests for JWT Context
*
* These tests call the REAL backend - no mocks.
* Backend must be running: docker-compose up
*
* Run with: RUN_INTEGRATION_TESTS=true npm run test
*
* Note: Most JWT operations require an authenticated session.
* Tests that require authentication verify 401 handling (expected for anonymous users).
*/
import { renderHook, act, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ReactNode } from 'react'
import { JWTContext, useJWT, useJWTRequired } from '../JWTContext'
import { describeIntegration, BACKEND_URL } from '../../testing'
import { startJWTServer, type JWTTestServer } from './jwtServer'
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<JWTContext config={{
autoObtain: false,
baseUrl: BACKEND_URL,
...config
}}>
{children}
</JWTContext>
)
describe('JWTContext', () => {
let server: JWTTestServer
beforeAll(async () => {
server = await startJWTServer()
})
afterAll(async () => {
await server.close()
})
beforeEach(() => {
server.setSession('anonymous')
})
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<JWTContext config={{ autoObtain: false, baseUrl: server.url, ...config }}>
{children}
</JWTContext>
)
}
}
}
describeIntegration('JWTContext (integration)', () => {
describe('Hook behavior outside provider', () => {
it('should return null when useJWT used outside JWTContext', () => {
describe('hooks outside the provider', () => {
it('should return null from useJWT', () => {
const { result } = renderHook(() => useJWT())
expect(result.current).toBeNull()
})
it('should throw when useJWTRequired used outside JWTContext', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
it('should throw from useJWTRequired', () => {
// React logs the render failure; silence it so the expected throw
// does not print a stack trace.
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
renderHook(() => useJWTRequired())
@@ -48,46 +49,106 @@ describeIntegration('JWTContext (integration)', () => {
})
})
describe('Token operations with real backend', () => {
it('should handle 401 when obtaining tokens as anonymous user', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
})
describe('obtainTokens', () => {
it('should resolve null without an error when the caller has no session', async () => {
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
await act(async () => {
const tokens = await result.current.obtainTokens()
// Anonymous users get 401, which returns null (not an error)
expect(tokens).toBeNull()
await expect(result.current.obtainTokens()).resolves.toBeNull()
})
expect(result.current.tokens).toBeNull()
// 401 is graceful - not an error state
expect(result.current.error).toBeNull()
expect(result.current.isLoading).toBe(false)
})
it('should store the issued pair when the session authenticates', async () => {
server.setSession('authenticated')
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
await act(async () => {
const issued = await result.current.obtainTokens()
expect(issued?.accessToken).toMatch(/^access-\d+$/)
})
expect(result.current.tokens?.accessToken).toMatch(/^access-\d+$/)
expect(result.current.tokens?.refreshToken).toMatch(/^refresh-\d+$/)
expect(result.current.tokens!.expiresAt).toBeGreaterThan(Date.now())
expect(result.current.error).toBeNull()
})
it('should handle 401 when refreshing tokens without valid refresh token', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
})
it('should surface a non-FORBIDDEN server error through error', async () => {
server.setSession('failing')
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
// Try to refresh without any tokens - should fail gracefully
await act(async () => {
const tokens = await result.current.refreshTokens()
expect(tokens).toBeNull()
await expect(result.current.obtainTokens()).resolves.toBeNull()
})
expect(result.current.error?.message).toBe('Token service unavailable')
expect(result.current.tokens).toBeNull()
})
})
describe('clearTokens (no backend needed)', () => {
it('should be safe to call clearTokens when no tokens are set', async () => {
// Verifies clearTokens doesn't throw or corrupt state when called
// with no tokens present (e.g., during logout when already logged out)
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
describe('refreshTokens', () => {
it('should resolve null without calling the server when nothing is held', async () => {
server.setSession('failing')
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
await act(async () => {
await expect(result.current.refreshTokens()).resolves.toBeNull()
})
expect(result.current.error).toBeNull()
})
it('should rotate the held pair', async () => {
server.setSession('authenticated')
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
await act(async () => {
await result.current.obtainTokens()
})
const first = result.current.tokens!
await act(async () => {
await result.current.refreshTokens()
})
expect(result.current.tokens!.accessToken).not.toBe(first.accessToken)
expect(result.current.tokens!.refreshToken).not.toBe(first.refreshToken)
})
it('should drop the held pair once the server rejects the refresh token', async () => {
server.setSession('authenticated')
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
await act(async () => {
await result.current.obtainTokens()
})
expect(result.current.tokens).not.toBeNull()
server.setSession('anonymous')
await act(async () => {
await expect(result.current.refreshTokens()).resolves.toBeNull()
})
expect(result.current.tokens).toBeNull()
expect(result.current.error).toBeNull()
})
})
describe('clearTokens', () => {
it('should drop a held pair', async () => {
server.setSession('authenticated')
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
await act(async () => {
await result.current.obtainTokens()
})
expect(result.current.tokens).not.toBeNull()
act(() => {
result.current.clearTokens()
})
@@ -98,55 +159,72 @@ describeIntegration('JWTContext (integration)', () => {
})
describe('getAccessToken', () => {
it('should return null when no tokens available', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
})
it('should return null when nothing is held', async () => {
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
let token: string | null = 'not-null'
await act(async () => {
token = await result.current.getAccessToken()
await expect(result.current.getAccessToken()).resolves.toBeNull()
})
})
it('should return the held token while it is outside the refresh buffer', async () => {
server.setSession('authenticated')
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
await act(async () => {
await result.current.obtainTokens()
})
const held = result.current.tokens!.accessToken
await act(async () => {
await expect(result.current.getAccessToken()).resolves.toBe(held)
})
})
it('should refresh first when the held token is inside the refresh buffer', async () => {
server.setSession('authenticated')
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper({ refreshBuffer: 3600, autoRefresh: false }),
})
expect(token).toBeNull()
await act(async () => {
await result.current.obtainTokens()
})
const held = result.current.tokens!.accessToken
await act(async () => {
await expect(result.current.getAccessToken()).resolves.not.toBe(held)
})
expect(result.current.tokens!.accessToken).not.toBe(held)
})
})
describe('autoObtain with real backend', () => {
it('should attempt auto-obtain and handle 401 for anonymous', async () => {
describe('autoObtain', () => {
it('should settle with no tokens for an anonymous caller', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper({ autoObtain: true }),
})
// Auto-obtain will attempt to get tokens but fail for anonymous user
await waitFor(() => {
// After auto-obtain completes, tokens should be null (401 response)
// or the loading state should be done
expect(result.current.isLoading).toBe(false)
}, { timeout: 5000 })
// Anonymous user won't have tokens
expect(result.current.tokens).toBeNull()
expect(result.current.error).toBeNull()
})
it('should hold tokens on mount for an authenticated caller', async () => {
server.setSession('authenticated')
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper({ autoObtain: true }),
})
await waitFor(() => {
expect(result.current.tokens).not.toBeNull()
}, { timeout: 5000 })
expect(result.current.tokens!.accessToken).toMatch(/^access-\d+$/)
})
})
})
/**
* Note on authenticated JWT tests:
*
* To fully test JWT token obtain/refresh, we would need:
* 1. An authenticated user session (login first)
* 2. Valid CSRF token handling
*
* These scenarios are better tested in E2E tests (Playwright/Cypress)
* where we can:
* 1. Navigate to login page
* 2. Submit credentials
* 3. Then test JWT token flows
*
* The tests above verify:
* - Hook API contract (throws/returns null outside provider)
* - Graceful 401 handling (anonymous users)
* - State management (clearTokens)
* - Integration with real backend (network calls happen)
*/

View File

@@ -1,10 +1,5 @@
/**
* Contract Tests for mizan JWT Server Functions
*
* Validates that the backend schema exports the expected JWT functions.
* These tests catch frontend/backend contract mismatches early.
*/
// The schema JSON is emitted into a consuming project's @/api directory, so
// this suite only resolves when run from inside such a project.
import mizanSchema from '@/api/generated.mizan.schema.json'
type mizanFunction = {

View File

@@ -1,14 +1,7 @@
/**
* Unit Tests for JWT Hooks
*
* Tests hook behavior in isolation (no backend required).
*/
import { renderHook } from '@testing-library/react'
import { ReactNode } from 'react'
import { JWTContext, useJWTReady } from '../JWTContext'
// Wrapper that provides JWTContext
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
return function Wrapper({ children }: { children: ReactNode }) {
return (

View File

@@ -0,0 +1,98 @@
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
interface CallFrame {
fn: string
args: Record<string, unknown>
}
export type SessionState = 'anonymous' | 'authenticated' | 'failing'
export interface JWTTestServer {
url: string
setSession: (state: SessionState) => void
close: () => Promise<void>
}
const TOKEN_LIFETIME_SECONDS = 300
function forbidden(message: string) {
return { error: true, code: 'FORBIDDEN', message }
}
/**
* HTTP server speaking the Mizan call envelope for `jwt_obtain` and
* `jwt_refresh`. Each issue rotates the refresh token, so presenting a
* superseded one comes back FORBIDDEN.
*/
export async function startJWTServer(): Promise<JWTTestServer> {
let session: SessionState = 'anonymous'
let issueCount = 0
let liveRefreshToken: string | null = null
function issue() {
issueCount += 1
liveRefreshToken = `refresh-${issueCount}`
return {
data: {
access_token: `access-${issueCount}`,
refresh_token: liveRefreshToken,
expires_in: TOKEN_LIFETIME_SECONDS,
},
}
}
function respond(frame: CallFrame) {
if (session === 'failing') {
return { error: true, code: 'SERVER_ERROR', message: 'Token service unavailable' }
}
if (frame.fn === 'jwt_obtain') {
return session === 'authenticated' ? issue() : forbidden('No session')
}
if (frame.fn === 'jwt_refresh') {
if (session !== 'authenticated' || frame.args.refresh_token !== liveRefreshToken) {
return forbidden('Refresh token rejected')
}
return issue()
}
return { error: true, code: 'NOT_FOUND', message: `Unknown function: ${frame.fn}` }
}
const http = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => {
const frame = JSON.parse(Buffer.concat(chunks).toString()) as CallFrame
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(respond(frame)))
})
})
await new Promise<void>((resolve) => {
http.listen(0, '127.0.0.1', resolve)
})
const { port } = http.address() as AddressInfo
return {
url: `http://127.0.0.1:${port}`,
setSession: (state: SessionState) => {
session = state
},
// Keep-alive sockets stay counted by the server until they are torn
// down, so closeAllConnections() is what lets close() call back.
close: () => new Promise<void>((resolve, reject) => {
http.closeAllConnections()
http.close((httpError) => {
if (httpError) {
reject(httpError)
return
}
resolve()
})
}),
}
}

View File

@@ -1,34 +1,17 @@
/**
* Integration Test Helper
*
* Integration tests require a running backend: docker-compose up
*
* To run integration tests:
* RUN_INTEGRATION_TESTS=true npm run test
*
* By default, integration tests are skipped in the regular test run.
*/
export const runIntegrationTests = process.env.RUN_INTEGRATION_TESTS === 'true'
// Type for Jest's describe function (simplied, avoids needing @types/jest at build time)
// The runner's `describe` global is declared locally because the build
// tsconfig has no test-runner type package on its include path.
type DescribeFn = {
(name: string, fn: () => void): void
skip: (name: string, fn: () => void) => void
}
// Declare global describe from Jest (only available in test environment)
declare const describe: DescribeFn
/**
* Use this instead of `describe` for integration test suites that require a backend.
* Tests will be skipped unless RUN_INTEGRATION_TESTS=true.
*/
/** `describe` when a live backend is available, `describe.skip` otherwise. */
export const describeIntegration = runIntegrationTests ? describe : describe.skip
/**
* Backend URL from environment or default localhost
*/
export const BACKEND_URL = (() => {
if (!process.env.NEXT_PUBLIC_HOST_URL) {
console.warn('[mizan/testing] NEXT_PUBLIC_HOST_URL not set, falling back to http://localhost')
@@ -36,7 +19,4 @@ export const BACKEND_URL = (() => {
return process.env.NEXT_PUBLIC_HOST_URL || 'http://localhost'
})()
/**
* WebSocket URL derived from backend URL
*/
export const WS_URL = BACKEND_URL.replace(/^http/, 'ws') + '/ws/'

View File

@@ -1,11 +1,7 @@
/**
* Shared utilities used across mizan-react.
*/
/** Default CSRF cookie name. Configurable via MizanProvider. */
/** Default CSRF cookie name. Replaced by configureCsrf. */
let _csrfCookieName = 'csrftoken'
/** Default CSRF header name. Configurable via MizanProvider. */
/** Default CSRF header name. Replaced by configureCsrf. */
let _csrfHeaderName = 'X-CSRFToken'
export function configureCsrf(cookieName: string, headerName: string): void {
@@ -21,7 +17,7 @@ export function getCsrfHeaderName(): string {
return _csrfHeaderName
}
/** Extract CSRF token from cookies. Returns null during SSR. */
/** Extract CSRF token from cookies. Returns null when there is no document. */
export function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(new RegExp(`${_csrfCookieName}=([^;]+)`))

View File

@@ -16,9 +16,6 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
exclude: [
// Requires @/api/generated.mizan.schema.json from consuming project
'src/jwt/__tests__/contract.test.ts',
],
exclude: ['src/jwt/__tests__/contract.test.ts'],
},
})

View File

@@ -1,6 +1 @@
import { vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
// Jest compatibility: existing tests use jest.fn(), jest.spyOn(), jest.mock()
// Vitest's `vi` object has the same API, so we alias it globally.
;(globalThis as any).jest = vi