/**
* Tests for ResourceContext - resource-context.tsx
*
* TDD RED phase: Write failing tests first.
*
* This includes tests for the stale registration bug identified in code review:
* Resource component uses useEffect with only [name] in dependency array,
* which can cause stale definitions when other props change.
*/
import { describe, it, expect, vi } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import { useState, type FC } from 'react'
import {
ResourceProvider,
useResource,
ResourcesProvider,
useResources,
type ResourcesContextValue,
} from '../../context/resource-context'
import type { ResourceDefinition, Identifier, ResourceContextValue } from '../../types'
// Helper component to consume single resource context
function ResourceConsumer({ onResource }: { onResource: (ctx: ResourceContextValue) => void }) {
const ctx = useResource()
onResource(ctx)
return (
{ctx.resource}
{ctx.id?.toString() ?? 'none'}
{ctx.hasList.toString()}
{ctx.hasEdit.toString()}
)
}
// Helper component to consume resources collection context
function ResourcesConsumer({ onResources }: { onResources: (ctx: ResourcesContextValue) => void }) {
const ctx = useResources()
onResources(ctx)
return (
{ctx.resources.length}
{ctx.resources.map((r) => r.name).join(',')}
)
}
const MockListComponent: FC = () => List
const MockEditComponent: FC<{ id: Identifier }> = ({ id }) => Edit {id}
describe('ResourceContext', () => {
describe('ResourceProvider', () => {
it('should provide resource name to children', () => {
render(
{}} />
)
expect(screen.getByTestId('resource').textContent).toBe('users')
})
it('should provide id when viewing/editing a record', () => {
render(
{}} />
)
expect(screen.getByTestId('id').textContent).toBe('123')
})
it('should indicate available views based on definition', () => {
const definition: ResourceDefinition = {
name: 'users',
list: MockListComponent,
edit: MockEditComponent,
}
render(
{}} />
)
expect(screen.getByTestId('has-list').textContent).toBe('true')
expect(screen.getByTestId('has-edit').textContent).toBe('true')
})
it('should indicate missing views when not in definition', () => {
const definition: ResourceDefinition = {
name: 'users',
// No list, edit, show, or create
}
render(
{}} />
)
expect(screen.getByTestId('has-list').textContent).toBe('false')
expect(screen.getByTestId('has-edit').textContent).toBe('false')
})
it('should handle numeric IDs', () => {
render(
{}} />
)
expect(screen.getByTestId('id').textContent).toBe('42')
})
it('should memoize context value when props are stable', () => {
let renderCount = 0
const contextValues: ResourceContextValue[] = []
function TrackedConsumer() {
const ctx = useResource()
renderCount++
contextValues.push(ctx)
return render {renderCount}
}
const { rerender } = render(
)
rerender(
)
// Context value should be the same object when props don't change
expect(contextValues[0]).toBe(contextValues[1])
})
})
describe('useResource', () => {
it('should throw error when used outside ResourceProvider', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
render( {}} />)
}).toThrow('useResource must be used within a ResourceProvider')
consoleError.mockRestore()
})
})
})
describe('ResourcesContext', () => {
describe('ResourcesProvider', () => {
it('should provide empty resources array by default', () => {
render(
{}} />
)
expect(screen.getByTestId('count').textContent).toBe('0')
})
it('should initialize with provided resources', () => {
const resources: ResourceDefinition[] = [
{ name: 'users', label: 'Users' },
{ name: 'products', label: 'Products' },
]
render(
{}} />
)
expect(screen.getByTestId('count').textContent).toBe('2')
expect(screen.getByTestId('names').textContent).toBe('users,products')
})
it('should allow registering new resources', () => {
let ctx: ResourcesContextValue | undefined
render(
{ ctx = c }} />
)
expect(screen.getByTestId('count').textContent).toBe('0')
act(() => {
ctx!.registerResource({ name: 'orders', label: 'Orders' })
})
// Note: This tests imperative registration - the display may not update
// because ResourcesProvider uses a ref-based state pattern
expect(ctx!.getResource('orders')).toBeDefined()
expect(ctx!.getResource('orders')?.label).toBe('Orders')
})
it('should allow unregistering resources', () => {
let ctx: ResourcesContextValue | undefined
const resources: ResourceDefinition[] = [
{ name: 'users', label: 'Users' },
{ name: 'products', label: 'Products' },
]
render(
{ ctx = c }} />
)
act(() => {
ctx!.unregisterResource('users')
})
expect(ctx!.getResource('users')).toBeUndefined()
expect(ctx!.getResource('products')).toBeDefined()
})
it('should allow getting a resource by name', () => {
let ctx: ResourcesContextValue | undefined
const resources: ResourceDefinition[] = [
{ name: 'users', label: 'Users', labelPlural: 'All Users' },
]
render(
{ ctx = c }} />
)
const resource = ctx!.getResource('users')
expect(resource?.name).toBe('users')
expect(resource?.label).toBe('Users')
expect(resource?.labelPlural).toBe('All Users')
})
it('should return undefined for non-existent resource', () => {
let ctx: ResourcesContextValue | undefined
render(
{ ctx = c }} />
)
expect(ctx!.getResource('nonexistent')).toBeUndefined()
})
it('should handle resource registration with same name (update)', () => {
let ctx: ResourcesContextValue | undefined
const resources: ResourceDefinition[] = [
{ name: 'users', label: 'Users' },
]
render(
{ ctx = c }} />
)
act(() => {
ctx!.registerResource({ name: 'users', label: 'Updated Users', list: MockListComponent })
})
const resource = ctx!.getResource('users')
expect(resource?.label).toBe('Updated Users')
expect(resource?.list).toBe(MockListComponent)
})
})
describe('useResources', () => {
it('should throw error when used outside ResourcesProvider', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
render( {}} />)
}).toThrow('useResources must be used within a ResourcesProvider')
consoleError.mockRestore()
})
})
describe('stale registration bug (issue ui-n710)', () => {
/**
* This test demonstrates the stale registration issue identified in code review:
* When a resource's view components change after initial registration,
* the registered definition may become stale because useEffect only
* depends on [name].
*
* This is a KNOWN LIMITATION that should either be fixed or documented.
*/
it('should update registered definition when view components change', () => {
let ctx: ResourcesContextValue | undefined
// Component that dynamically changes resource definition
function DynamicResource() {
const [hasEdit, setHasEdit] = useState(false)
const definition: ResourceDefinition = {
name: 'users',
label: 'Users',
list: MockListComponent,
...(hasEdit ? { edit: MockEditComponent } : {}),
}
return (
<>
{ ctx = c }} />
setHasEdit(true)} data-testid="add-edit">
Add Edit
>
)
}
render( )
// Initially no edit
expect(ctx!.getResource('users')?.edit).toBeUndefined()
// Add edit component
act(() => {
screen.getByTestId('add-edit').click()
})
// This test documents the expected behavior:
// After changing the resource definition, getResource should return
// the updated definition with the edit component.
// If this fails, it means the stale registration bug exists.
expect(ctx!.getResource('users')?.edit).toBe(MockEditComponent)
})
})
})