Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
}));
vi.mock('@/lib/api-client', () => ({ api: { post: vi.fn() } }));
vi.mock('@/components/file-uploader', () => ({ FileUploader: () => <div /> }));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

// Light design-system mock: render a Select as a native <select> so the
// dropdown options and matrix cells are queryable in jsdom.
vi.mock('@trycompai/design-system', () => {
const Passthrough = ({ children }: any) => <div>{children}</div>;
return {
Alert: Passthrough,
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
Field: Passthrough,
FieldError: () => null,
FieldGroup: Passthrough,
FieldLabel: ({ children, htmlFor }: any) => <label htmlFor={htmlFor}>{children}</label>,
Input: (props: any) => <input {...props} />,
Section: Passthrough,
Text: ({ children }: any) => <span>{children}</span>,
Textarea: (props: any) => <textarea {...props} />,
Select: ({ value, onValueChange, children }: any) => (
<select
data-testid="ds-select"
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
>
{children}
</select>
),
SelectContent: ({ children }: any) => <>{children}</>,
SelectItem: ({ value, children }: any) => <option value={value}>{children}</option>,
SelectTrigger: () => null,
SelectValue: () => null,
};
});

import { CompanySubmissionWizard } from './CompanySubmissionWizard';

describe('CompanySubmissionWizard — account-types rendering', () => {
it('renders the 10 seeded rows with Allowed/Disallowed dropdowns and prefilled values', async () => {
render(<CompanySubmissionWizard organizationId="org_test" formType="account-types" />);

// The Account Types table (matrix) lives on the second step.
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));

// 10 default rows → 10 status dropdowns, each offering Allowed/Disallowed.
await waitFor(() => {
expect(screen.getAllByTestId('ds-select')).toHaveLength(10);
});
const selects = screen.getAllByTestId('ds-select') as HTMLSelectElement[];
const options = within(selects[0]).getAllByRole('option').map((o) => o.textContent);
expect(options).toEqual(['Allowed', 'Disallowed']);

// First row is pre-filled per the spec.
expect(selects[0].value).toBe('Allowed');
expect(screen.getByDisplayValue('Individual')).toBeTruthy();
expect(screen.getByDisplayValue('Needed by each employee/worker')).toBeTruthy();
// A Disallowed default row has a blank justification.
expect(screen.getByDisplayValue('Developer')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,46 @@ function createEmptyMatrixRow(columns: ReadonlyArray<MatrixColumnDefinition>): M
return Object.fromEntries(columns.map((column) => [column.key, '']));
}

// A single matrix cell: a dropdown when the column declares `type: 'select'`,
// otherwise a free-text input.
function MatrixCellControl({
id,
column,
value,
onChange,
}: {
id: string;
column: MatrixColumnDefinition;
value: string;
onChange: (value: string) => void;
}) {
if (column.type === 'select' && column.options) {
return (
<Select value={value} onValueChange={(next) => onChange(next ?? '')}>
<SelectTrigger id={id}>
<SelectValue placeholder={column.placeholder ?? 'Select...'} />
</SelectTrigger>
<SelectContent>
{column.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

return (
<Input
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={column.placeholder}
/>
);
}

export function CompanySubmissionWizard({
organizationId,
formType,
Expand Down Expand Up @@ -150,7 +190,11 @@ export function CompanySubmissionWizard({
}

for (const matrixField of matrixFields) {
defaults[matrixField.key] = [createEmptyMatrixRow(matrixField.columns)];
const seedRows = matrixField.defaultRows;
defaults[matrixField.key] =
seedRows && seedRows.length > 0
? seedRows.map((row) => ({ ...createEmptyMatrixRow(matrixField.columns), ...row }))
: [createEmptyMatrixRow(matrixField.columns)];
}

return defaults;
Expand Down Expand Up @@ -783,18 +827,13 @@ export function CompanySubmissionWizard({
{column.description}
</Text>
)}
<Input
<MatrixCellControl
id={`${field.key}-${rowIndex}-${column.key}`}
column={column}
value={row[column.key] ?? ''}
onChange={(event) =>
updateMatrixCell(
field,
rowIndex,
column.key,
event.target.value,
)
onChange={(value) =>
updateMatrixCell(field, rowIndex, column.key, value)
}
placeholder={column.placeholder}
/>
</Field>
))}
Expand Down Expand Up @@ -948,13 +987,13 @@ export function CompanySubmissionWizard({
{column.description}
</Text>
)}
<Input
<MatrixCellControl
id={`${field.key}-${rowIndex}-${column.key}`}
column={column}
value={row[column.key] ?? ''}
onChange={(event) =>
updateMatrixCell(field, rowIndex, column.key, event.target.value)
onChange={(value) =>
updateMatrixCell(field, rowIndex, column.key, value)
}
placeholder={column.placeholder}
/>
</Field>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
evidenceFormDefinitions,
evidenceFormSubmissionSchemaMap,
} from '@/app/(app)/[orgId]/documents/forms';

const schema = evidenceFormSubmissionSchemaMap['account-types'];
const definition = evidenceFormDefinitions['account-types'];

function submission(rows: Array<Record<string, string>>) {
return { submissionDate: '2026-06-10', accountTypeRows: rows };
}

describe('account-types document type definition', () => {
it('has the requested title and intro', () => {
expect(definition.title).toBe('New Account Types Submission');
expect(definition.description).toBe('Document allowed Account types, with justification.');
});

it('is hidden + optional so it stays out of the global browse list and scores', () => {
// NIST-specific: must not appear in every org's Documents browse list
// (hidden) and must not change the org-wide expected-documents score
// (the scorers count `!hidden && !optional`). It is still reachable and
// counted via any control it is explicitly linked to.
expect(definition.hidden).toBe(true);
expect(definition.optional).toBe(true);
});

it('exposes an Allowed/Disallowed dropdown column and a justification column', () => {
const matrix = definition.fields.find((f) => f.type === 'matrix');
const columns = matrix?.columns ?? [];
const status = columns.find((c) => c.key === 'status');
expect(status?.type).toBe('select');
expect(status?.options?.map((o) => o.value)).toEqual(['Allowed', 'Disallowed']);
expect(columns.map((c) => c.key)).toEqual(['accountType', 'status', 'justification']);
});

it('ships the 10 default rows from the spec, pre-filled correctly', () => {
const matrix = definition.fields.find((f) => f.type === 'matrix');
const rows = matrix?.defaultRows ?? [];
expect(rows).toHaveLength(10);
expect(rows[0]).toMatchObject({
accountType: 'Individual',
status: 'Allowed',
justification: 'Needed by each employee/worker',
});
expect(rows.find((r) => r.accountType === 'Developer')).toMatchObject({
status: 'Disallowed',
justification: '',
});
// Every Allowed default row carries a justification (so defaults pass validation).
for (const row of rows) {
if (row.status === 'Allowed') expect(row.justification.trim().length).toBeGreaterThan(0);
}
});
});

describe('account-types conditional validation', () => {
it('accepts the shipped default rows as-is', () => {
const matrix = definition.fields.find((f) => f.type === 'matrix');
const rows = [...(matrix?.defaultRows ?? [])];
expect(schema.safeParse(submission(rows)).success).toBe(true);
});

it('requires a justification when a row is Allowed', () => {
const result = schema.safeParse(
submission([{ accountType: 'Contractor', status: 'Allowed', justification: '' }]),
);
expect(result.success).toBe(false);
if (!result.success) {
const issue = result.error.issues.find((i) => i.path.includes('justification'));
expect(issue?.message).toMatch(/justification is required/i);
}
});

it('allows a blank justification when a row is Disallowed', () => {
const result = schema.safeParse(
submission([{ accountType: 'Guest account', status: 'Disallowed', justification: '' }]),
);
expect(result.success).toBe(true);
});

it('rejects a status that is neither Allowed nor Disallowed', () => {
const result = schema.safeParse(
submission([{ accountType: 'Weird', status: 'Maybe', justification: '' }]),
);
expect(result.success).toBe(false);
});

it('requires at least one filled account-type row', () => {
const result = schema.safeParse(
submission([{ accountType: '', status: '', justification: '' }]),
);
expect(result.success).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export type MatrixColumnDefinition = {
required?: boolean;
placeholder?: string;
description?: string;
type?: 'text' | 'select';
options?: ReadonlyArray<{ label: string; value: string }>;
};

export function isMatrixField(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const DOCUMENT_TYPE_LABELS: Record<EvidenceFormType, string> = {
employee_performance_evaluation: 'Employee Performance Evaluation',
network_diagram: 'Network Diagram',
tabletop_exercise: 'Tabletop Exercise',
account_types: 'Account Types',
};

export const ALL_DOCUMENT_TYPES = Object.keys(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

// Stub heavy deps; render each cell's value as text so we can read row order.
vi.mock('@/app/lib/api-client', () => ({ apiClient: vi.fn() }));
vi.mock('../../components/AddExistingItemDialog', () => ({ AddExistingItemDialog: () => null }));
vi.mock('@trycompai/ui', () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock('../../components/table', () => ({
DateCell: () => null,
MultiSelectCell: () => null,
RelationalCell: () => null,
EditableCell: ({ value, columnId }: { value: string | null; columnId: string }) => (
<span data-testid={`cell-${columnId}`}>{value}</span>
),
}));

function gridRow(id: string, name: string) {
return {
id,
name,
description: '',
controlFamily: null,
policyTemplates: [],
requirements: [],
taskTemplates: [],
documentTypes: [],
policyTemplatesLength: 0,
requirementsLength: 0,
taskTemplatesLength: 0,
documentTypesLength: 0,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
}

// Rows are returned in deliberately non-alphabetical (creation-ish) order.
vi.mock('./hooks/useChangeTracking', () => ({
simpleUUID: () => 'temp-id',
useChangeTracking: () => ({
data: [gridRow('c1', 'Zebra control'), gridRow('c2', 'Apple control'), gridRow('c3', 'Mango control')],
updateCell: vi.fn(),
batchUpdateCells: vi.fn(),
updateRelational: vi.fn(),
addRow: vi.fn(),
deleteRow: vi.fn(),
getRowClassName: () => '',
handleCommit: vi.fn(),
handleCancel: vi.fn(),
isDirty: false,
createdIds: new Set<string>(),
changesSummary: '',
}),
}));

import { ControlsClientPage } from './ControlsClientPage';

describe('ControlsClientPage', () => {
it('opens with controls sorted by Name A–Z regardless of input order (CS-511)', () => {
render(<ControlsClientPage initialControls={[]} frameworkId="frk_1" />);

const names = screen.getAllByTestId('cell-name').map((el) => el.textContent);
expect(names).toEqual(['Apple control', 'Mango control', 'Zebra control']);
});

it('does not render the Control Family column or Manage Families control (CS-512)', () => {
render(<ControlsClientPage initialControls={[]} frameworkId="frk_1" />);

expect(screen.queryAllByTestId('cell-controlFamily')).toHaveLength(0);
expect(screen.queryByText('Control Family')).toBeNull();
expect(screen.queryByText('Manage Families')).toBeNull();
});
});
Loading
Loading