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
157 changes: 157 additions & 0 deletions src/components/__tests__/LocalDefinitionsForm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref, shallowRef } from 'vue';
import { createPinia, setActivePinia } from 'pinia';

const mockExecute = vi.fn().mockResolvedValue({});
const mockData = shallowRef(undefined);
const mockIsLoading = ref(false);

const mockUseDataApi = vi.fn().mockReturnValue({
data: mockData,
isLoading: mockIsLoading,
execute: mockExecute,
});

vi.mock('@/composables/axios', () => ({
useDataApi: (...args: unknown[]) => mockUseDataApi(...args),
decamelizeKeys: vi.fn((data: unknown) => JSON.stringify(data)),
}));

vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: vi.fn(),
remove: vi.fn(),
}),
}));

import LocalDefinitionsForm from '../poam/LocalDefinitionsForm.vue';

describe('LocalDefinitionsForm', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockData.value = undefined;
mockIsLoading.value = false;
});

const mountForm = (props?: { localDefinitions?: object }) => {
return mount(LocalDefinitionsForm, {
props: {
poamId: 'test-poam-id',
...props,
},
});
};

describe('API URL', () => {
it('uses the correct API URL (singular plan-of-action-and-milestones)', () => {
mountForm();

const url = mockUseDataApi.mock.calls[0][0];
expect(url).toBe(
'/api/oscal/plan-of-action-and-milestones/test-poam-id/local-definitions',
);
expect(url).not.toContain('plan-of-actions-and-milestones');
});
});

describe('Form submission - always uses PUT', () => {
it('uses PUT when creating new local definitions (no existing data)', async () => {
mockData.value = {
components: [],
inventoryItems: [],
remarks: '',
} as never;

const wrapper = mountForm();
await wrapper.find('form').trigger('submit');

expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({ method: 'PUT' }),
);
});

it('uses PUT when editing existing local definitions', async () => {
const existing = {
components: [
{
uuid: 'comp-1',
title: 'Existing',
type: 'software',
status: { state: 'operational' },
},
],
inventoryItems: [],
remarks: 'test',
};

mockData.value = existing as never;

const wrapper = mountForm({ localDefinitions: existing });
await wrapper.find('form').trigger('submit');

expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({ method: 'PUT' }),
);
});

it('never uses POST', async () => {
mockData.value = {
components: [],
inventoryItems: [],
remarks: '',
} as never;

const wrapper = mountForm();
await wrapper.find('form').trigger('submit');

expect(mockExecute).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'POST' }),
);
});
});

describe('Form button label', () => {
it('shows "Create" when no existing local definitions', () => {
const wrapper = mountForm();

const submitBtn = wrapper.find('button[type="submit"]');
expect(submitBtn.text()).toBe('Create');
});

it('shows "Update" when editing existing local definitions', () => {
const wrapper = mountForm({
localDefinitions: { components: [], inventoryItems: [], remarks: '' },
});

const submitBtn = wrapper.find('button[type="submit"]');
expect(submitBtn.text()).toBe('Update');
});
});

describe('Form data cleanup on submit', () => {
it('filters out components missing required fields before submit', async () => {
mockData.value = {
components: [],
inventoryItems: [],
remarks: '',
} as never;

const wrapper = mountForm();

// Add a component with missing fields via the button
const addBtn = wrapper
.findAll('button[type="button"]')
.find((b) => b.text().includes('Add Component'));
await addBtn!.trigger('click');

// Submit — the empty component should be filtered out
await wrapper.find('form').trigger('submit');

expect(mockExecute).toHaveBeenCalled();
const callData = mockExecute.mock.calls[0][0].data;
expect(callData.components).toHaveLength(0);
});
});
});
20 changes: 6 additions & 14 deletions src/components/poam/LocalDefinitionsForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ const {
isLoading: loading,
execute: saveLD,
} = useDataApi<POAMLocalDefinitions>(
`/api/oscal/plan-of-actions-and-milestones/${props.poamId}/local-definitions`,
`/api/oscal/plan-of-action-and-milestones/${props.poamId}/local-definitions`,
null,
{ immediate: false },
);
Expand Down Expand Up @@ -428,19 +428,11 @@ async function handleSubmit() {
formData.value.assessmentAssets.components.filter((c) => c.title);
}

if (props.localDefinitions) {
await saveLD({
data: formData.value,
method: 'PUT',
transformRequest: [decamelizeKeys],
});
} else {
await saveLD({
data: formData.value,
method: 'POST',
transformRequest: [decamelizeKeys],
});
}
await saveLD({
data: formData.value,
method: 'PUT',
transformRequest: [decamelizeKeys],
});

emit('saved', newLD.value!);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref, shallowRef } from 'vue';
import { createPinia, setActivePinia } from 'pinia';

// Shared mock state that tests can manipulate
const mockData = shallowRef(undefined);
const mockError = ref(null);
const mockIsLoading = ref(false);

vi.mock('@/composables/axios', () => ({
useDataApi: () => ({
data: mockData,
error: mockError,
isLoading: mockIsLoading,
}),
}));

vi.mock('vue-router', () => ({
useRoute: () => ({
params: { id: 'test-poam-id' },
}),
}));

vi.mock('@/volt/Dialog.vue', () => ({
default: {
name: 'Dialog',
template: '<div class="dialog"><slot /></div>',
props: ['visible', 'size', 'modal', 'header'],
},
}));

vi.mock('@/components/poam/LocalDefinitionsForm.vue', () => ({
default: {
name: 'LocalDefinitionsForm',
template: '<div class="local-definitions-form" />',
props: ['poamId', 'localDefinitions'],
emits: ['cancel', 'saved'],
},
}));

import PlanOfActionAndMilestonesLocalDefinitionsView from '../plan-of-actions-and-milestones/PlanOfActionAndMilestonesLocalDefinitionsView.vue';

describe('PlanOfActionAndMilestonesLocalDefinitionsView', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockData.value = undefined;
mockError.value = null;
mockIsLoading.value = false;
});

describe('Loading state', () => {
it('shows loading message when data is loading', () => {
mockIsLoading.value = true;

const wrapper = mount(PlanOfActionAndMilestonesLocalDefinitionsView);

expect(wrapper.text()).toContain('Loading local definitions data...');
});
});

describe('Error handling', () => {
it('shows error message for non-404 errors', () => {
mockError.value = {
response: { status: 500 },
message: 'Internal Server Error',
} as never;

const wrapper = mount(PlanOfActionAndMilestonesLocalDefinitionsView);

expect(wrapper.text()).toContain('Error loading local definitions data');
});

it('does NOT show error for 404 responses', () => {
mockError.value = {
response: { status: 404 },
message: 'Not Found',
} as never;

const wrapper = mount(PlanOfActionAndMilestonesLocalDefinitionsView);

expect(wrapper.text()).not.toContain('Error loading local definitions');
// Should show the empty/create state instead
expect(wrapper.text()).toContain('No local definitions data available');
});

it('does NOT show error for 404 and shows create button', () => {
mockError.value = {
response: { status: 404 },
message: 'Not Found',
} as never;

const wrapper = mount(PlanOfActionAndMilestonesLocalDefinitionsView);

const createBtn = wrapper.find('button');
expect(createBtn.exists()).toBe(true);
expect(createBtn.text()).toContain('Create');
});
});

describe('Empty state', () => {
it('shows empty state with create button when no data', () => {
mockData.value = undefined;

const wrapper = mount(PlanOfActionAndMilestonesLocalDefinitionsView);

expect(wrapper.text()).toContain('No local definitions data available');
});
});

describe('Data display', () => {
it('renders components when local definitions have components', () => {
mockData.value = {
components: [
{
uuid: 'comp-1',
title: 'Test Component',
type: 'software',
description: 'A test component',
status: { state: 'operational' },
},
],
} as never;

const wrapper = mount(PlanOfActionAndMilestonesLocalDefinitionsView);

expect(wrapper.text()).toContain('Test Component');
expect(wrapper.text()).toContain('software');
expect(wrapper.text()).toContain('operational');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,13 @@
<h2 class="text-xl font-semibold text-gray-900 dark:text-slate-300">
Local Definitions
</h2>
<div class="flex gap-2">
<button
@click="showCreateModal = true"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md"
>
Create New Local Definitions
</button>
<button
v-if="localDefinitions"
@click="showEditModal = true"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md"
>
Edit Local Definitions
</button>
</div>
<button
v-if="localDefinitions"
@click="showEditModal = true"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md"
>
Edit Local Definitions
</button>
</div>

<div v-if="loading" class="text-center py-8">
Expand Down Expand Up @@ -253,6 +245,7 @@ import Dialog from '@/volt/Dialog.vue';
import LocalDefinitionsForm from '@/components/poam/LocalDefinitionsForm.vue';
import { useDataApi } from '@/composables/axios';
import { getIdFromRoute } from '../../utils/get-poam-id-from-route';
import type { AxiosError } from 'axios';

const route = useRoute();

Expand All @@ -267,12 +260,20 @@ const dialogHeader = computed(() =>

const {
data: localDefinitions,
error,
error: rawError,
isLoading: loading,
} = useDataApi<POAMLocalDefinitions | null>(
`/api/oscal/plan-of-action-and-milestones/${route.params.id}/local-definitions`,
);

// A 404 means no local definitions exist yet — treat as empty, not as an error
const error = computed(() => {
if (!rawError.value) return null;
const status = (rawError.value as AxiosError)?.response?.status;
if (status === 404) return null;
return rawError.value;
});

function handleLocalDefinitionsSaved(
savedLocalDefinitions: POAMLocalDefinitions,
) {
Expand Down
Loading