diff --git a/src/admin/src/api/auditLogs.ts b/src/admin/src/api/auditLogs.ts new file mode 100644 index 0000000..5ecb1e6 --- /dev/null +++ b/src/admin/src/api/auditLogs.ts @@ -0,0 +1,35 @@ +import apiClient from './client'; +import type { AuditLogResponse, AuditLogFilter, PaginatedResponse } from '@/types/api'; + +function toParams(filter: AuditLogFilter) { + return { + resourceType: filter.resourceType ?? undefined, + action: filter.action ?? undefined, + from: filter.from ?? undefined, + to: filter.to ?? undefined, + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 20, + }; +} + +export async function listAuditLogsByOrganization( + orgId: number, + filter: AuditLogFilter = {}, +): Promise> { + const { data } = await apiClient.get>( + `/v1/organisations/${orgId}/audit-logs`, + { params: toParams(filter) }, + ); + return data; +} + +export async function listAuditLogsByProject( + projectId: number, + filter: AuditLogFilter = {}, +): Promise> { + const { data } = await apiClient.get>( + `/v1/projects/${projectId}/audit-logs`, + { params: toParams(filter) }, + ); + return data; +} diff --git a/src/admin/src/api/webhooks.ts b/src/admin/src/api/webhooks.ts new file mode 100644 index 0000000..2558f71 --- /dev/null +++ b/src/admin/src/api/webhooks.ts @@ -0,0 +1,88 @@ +import apiClient from './client'; +import type { + WebhookResponse, + WebhookDeliveryLogResponse, + CreateWebhookRequest, +} from '@/types/api'; + +// ─── Organization webhooks ──────────────────────────────────────────────────── + +export async function listOrgWebhooks(orgId: number): Promise { + const { data } = await apiClient.get(`/v1/organisations/${orgId}/webhooks`); + return data; +} + +export async function createOrgWebhook( + orgId: number, + request: CreateWebhookRequest, +): Promise { + const { data } = await apiClient.post( + `/v1/organisations/${orgId}/webhooks`, + request, + ); + return data; +} + +export async function updateOrgWebhook( + orgId: number, + webhookId: number, + request: CreateWebhookRequest, +): Promise { + const { data } = await apiClient.put( + `/v1/organisations/${orgId}/webhooks/${webhookId}`, + request, + ); + return data; +} + +export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise { + await apiClient.delete(`/v1/organisations/${orgId}/webhooks/${webhookId}`); +} + +// ─── Environment webhooks ───────────────────────────────────────────────────── + +export async function listEnvWebhooks(envApiKey: string): Promise { + const { data } = await apiClient.get( + `/v1/environments/${envApiKey}/webhooks`, + ); + return data; +} + +export async function createEnvWebhook( + envApiKey: string, + request: CreateWebhookRequest, +): Promise { + const { data } = await apiClient.post( + `/v1/environments/${envApiKey}/webhooks`, + request, + ); + return data; +} + +export async function updateEnvWebhook( + envApiKey: string, + webhookId: number, + request: CreateWebhookRequest, +): Promise { + const { data } = await apiClient.put( + `/v1/environments/${envApiKey}/webhooks/${webhookId}`, + request, + ); + return data; +} + +export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise { + await apiClient.delete(`/v1/environments/${envApiKey}/webhooks/${webhookId}`); +} + +// ─── Delivery logs ──────────────────────────────────────────────────────────── + +export async function listWebhookDeliveries( + envApiKey: string, + webhookId: number, +): Promise { + const { data } = await apiClient.get( + `/v1/environments/${envApiKey}/webhooks/${webhookId}/deliveries`, + ); + return data; +} diff --git a/src/admin/src/components/settings/WebhookDeliveryHistory.vue b/src/admin/src/components/settings/WebhookDeliveryHistory.vue new file mode 100644 index 0000000..09766f8 --- /dev/null +++ b/src/admin/src/components/settings/WebhookDeliveryHistory.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/admin/src/components/settings/WebhookDialog.vue b/src/admin/src/components/settings/WebhookDialog.vue new file mode 100644 index 0000000..47d97a5 --- /dev/null +++ b/src/admin/src/components/settings/WebhookDialog.vue @@ -0,0 +1,172 @@ + + + diff --git a/src/admin/src/components/settings/WebhooksPanel.vue b/src/admin/src/components/settings/WebhooksPanel.vue new file mode 100644 index 0000000..3d4c7f6 --- /dev/null +++ b/src/admin/src/components/settings/WebhooksPanel.vue @@ -0,0 +1,283 @@ + + + diff --git a/src/admin/src/types/api.ts b/src/admin/src/types/api.ts index 3966670..a3f5fdf 100644 --- a/src/admin/src/types/api.ts +++ b/src/admin/src/types/api.ts @@ -300,8 +300,8 @@ export interface AuditLogResponse { export interface AuditLogFilter { resourceType?: string | null; action?: string | null; - startDate?: string | null; - endDate?: string | null; + from?: string | null; + to?: string | null; page?: number; pageSize?: number; } @@ -336,10 +336,15 @@ export interface UpdateWebhookRequest { export interface WebhookDeliveryLogResponse { id: number; - status: WebhookDeliveryStatus; + webhookId: number; + eventType: string; + success: boolean; + responseStatusCode: number | null; + responseBody: string | null; + errorMessage: string | null; attemptNumber: number; - responseCode: number | null; - createdAt: string; + attemptedAt: string; + duration: string; } // ─── Identities ─────────────────────────────────────────────────────────────── diff --git a/src/admin/src/views/AuditLogsView.vue b/src/admin/src/views/AuditLogsView.vue index 6dba3a7..321bf9c 100644 --- a/src/admin/src/views/AuditLogsView.vue +++ b/src/admin/src/views/AuditLogsView.vue @@ -1,18 +1,303 @@ diff --git a/src/admin/tests/unit/components/settings/WebhookDeliveryHistory.spec.ts b/src/admin/tests/unit/components/settings/WebhookDeliveryHistory.spec.ts new file mode 100644 index 0000000..d1755f2 --- /dev/null +++ b/src/admin/tests/unit/components/settings/WebhookDeliveryHistory.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import WebhookDeliveryHistory from '@/components/settings/WebhookDeliveryHistory.vue'; +import type { WebhookDeliveryLogResponse } from '@/types/api'; + +jest.mock('@/api/webhooks'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as webhooksApi from '@/api/webhooks'; + +const mockDeliveries: WebhookDeliveryLogResponse[] = [ + { + id: 1, webhookId: 10, eventType: 'feature.updated', success: true, + responseStatusCode: 200, responseBody: 'OK', errorMessage: null, + attemptNumber: 1, attemptedAt: '2026-01-10T10:00:00Z', duration: '00:00:00.123', + }, + { + id: 2, webhookId: 10, eventType: 'feature.created', success: false, + responseStatusCode: 500, responseBody: null, errorMessage: 'Internal Server Error', + attemptNumber: 3, attemptedAt: '2026-01-11T11:00:00Z', duration: '00:00:01.500', + }, +]; + +function mountComponent(props: Record = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(WebhookDeliveryHistory, { + props: { envApiKey: 'env-dev', webhookId: 10, ...props }, + global: { plugins: [router] }, + }); +} + +describe('WebhookDeliveryHistory', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + }); + + describe('WhenMounted', () => { + it('ThenDeliveriesAreLoaded', async () => { + jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries); + + mountComponent(); + await flushPromises(); + + expect(webhooksApi.listWebhookDeliveries).toHaveBeenCalledWith('env-dev', 10); + }); + + it('ThenNoDeliveriesMessageIsShownWhenEmpty', async () => { + jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue([]); + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="no-deliveries-message"]').exists()).toBe(true); + }); + + it('ThenDeliveriesTableIsRendered', async () => { + jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries); + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="deliveries-table"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="delivery-row-1"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="delivery-row-2"]').exists()).toBe(true); + }); + }); + + describe('WhenRenderingDeliveryStatuses', () => { + it('ThenSuccessfulDeliveryShowsSuccessChip', async () => { + jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries); + + const wrapper = mountComponent(); + await flushPromises(); + + const successChip = wrapper.find('[data-testid="delivery-status-1"]'); + expect(successChip.exists()).toBe(true); + expect(successChip.text()).toContain('Success'); + }); + + it('ThenFailedDeliveryShowsFailedChip', async () => { + jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries); + + const wrapper = mountComponent(); + await flushPromises(); + + const failedChip = wrapper.find('[data-testid="delivery-status-2"]'); + expect(failedChip.exists()).toBe(true); + expect(failedChip.text()).toContain('Failed'); + }); + + it('ThenEventTypesAreDisplayed', async () => { + jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries); + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.text()).toContain('feature.updated'); + expect(wrapper.text()).toContain('feature.created'); + }); + + it('ThenAttemptNumbersAreDisplayed', async () => { + jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries); + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.text()).toContain('3'); // attemptNumber of second delivery + }); + }); +}); diff --git a/src/admin/tests/unit/components/settings/WebhookDialog.spec.ts b/src/admin/tests/unit/components/settings/WebhookDialog.spec.ts new file mode 100644 index 0000000..563b9d5 --- /dev/null +++ b/src/admin/tests/unit/components/settings/WebhookDialog.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import WebhookDialog from '@/components/settings/WebhookDialog.vue'; +import type { WebhookResponse } from '@/types/api'; + +jest.mock('@/api/webhooks'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as webhooksApi from '@/api/webhooks'; + +const mockWebhook: WebhookResponse = { + id: 1, url: 'https://example.com/hook', secret: 'mysecret', scope: 'Organization', + enabled: true, environmentId: null, organizationId: 1, createdAt: '2026-01-01T00:00:00Z', +}; + +const dialogStub = { template: '
' }; + +function mountDialog(props: Record = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(WebhookDialog, { + props: { modelValue: true, orgId: 1, ...props }, + global: { plugins: [router], stubs: { 'v-dialog': dialogStub } }, + }); +} + +describe('WebhookDialog', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + }); + + describe('WhenOpenedForCreate', () => { + it('ThenDialogIsRendered', () => { + const wrapper = mountDialog(); + expect(wrapper.find('[data-testid="webhook-dialog"]').exists()).toBe(true); + }); + + it('ThenTitleShowsAddWebhook', () => { + const wrapper = mountDialog(); + expect(wrapper.text()).toContain('Add Webhook'); + }); + + it('ThenCreateOrgWebhookIsCalledOnSave', async () => { + const created = { ...mockWebhook, id: 99 }; + jest.mocked(webhooksApi.createOrgWebhook).mockResolvedValue(created); + + const wrapper = mountDialog(); + + await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper).vm.$emit('update:modelValue', 'https://example.com/new'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click'); + await flushPromises(); + + expect(webhooksApi.createOrgWebhook).toHaveBeenCalledWith( + 1, + expect.objectContaining({ url: 'https://example.com/new' }), + ); + }); + + it('ThenSavedEventIsEmitted', async () => { + const created = { ...mockWebhook, id: 99 }; + jest.mocked(webhooksApi.createOrgWebhook).mockResolvedValue(created); + + const wrapper = mountDialog(); + + await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper).vm.$emit('update:modelValue', 'https://example.com/new'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click'); + await flushPromises(); + + expect(wrapper.emitted('saved')).toBeTruthy(); + expect(wrapper.emitted('saved')![0]).toEqual([created]); + }); + }); + + describe('WhenOpenedForEdit', () => { + it('ThenTitleShowsEditWebhook', () => { + const wrapper = mountDialog({ webhook: mockWebhook }); + expect(wrapper.text()).toContain('Edit Webhook'); + }); + + it('ThenFormIsPrePopulatedWithWebhookData', () => { + const wrapper = mountDialog({ webhook: mockWebhook }); + const urlInput = wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper; + expect(urlInput.props('modelValue')).toBe(mockWebhook.url); + }); + + it('ThenUpdateOrgWebhookIsCalledOnSave', async () => { + const updated = { ...mockWebhook, url: 'https://example.com/updated' }; + jest.mocked(webhooksApi.updateOrgWebhook).mockResolvedValue(updated); + + const wrapper = mountDialog({ webhook: mockWebhook }); + + await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click'); + await flushPromises(); + + expect(webhooksApi.updateOrgWebhook).toHaveBeenCalledWith( + 1, mockWebhook.id, + expect.objectContaining({ url: mockWebhook.url }), + ); + }); + }); + + describe('WhenEnvScopedDialogIsUsed', () => { + it('ThenCreateEnvWebhookIsCalledOnSave', async () => { + const created = { ...mockWebhook, id: 99, scope: 'Environment' as const }; + jest.mocked(webhooksApi.createEnvWebhook).mockResolvedValue(created); + + const wrapper = mountDialog({ orgId: undefined, envApiKey: 'env-dev' }); + + await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper).vm.$emit('update:modelValue', 'https://example.com/new'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click'); + await flushPromises(); + + expect(webhooksApi.createEnvWebhook).toHaveBeenCalledWith( + 'env-dev', + expect.objectContaining({ url: 'https://example.com/new' }), + ); + }); + }); +}); diff --git a/src/admin/tests/unit/components/settings/WebhooksPanel.spec.ts b/src/admin/tests/unit/components/settings/WebhooksPanel.spec.ts new file mode 100644 index 0000000..17b7d47 --- /dev/null +++ b/src/admin/tests/unit/components/settings/WebhooksPanel.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import WebhooksPanel from '@/components/settings/WebhooksPanel.vue'; +import type { WebhookResponse } from '@/types/api'; + +jest.mock('@/api/webhooks'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as webhooksApi from '@/api/webhooks'; + +const mockOrgWebhooks: WebhookResponse[] = [ + { + id: 1, url: 'https://example.com/hook1', secret: null, scope: 'Organization', + enabled: true, environmentId: null, organizationId: 1, createdAt: '2026-01-01T00:00:00Z', + }, + { + id: 2, url: 'https://example.com/hook2', secret: 'abc', scope: 'Organization', + enabled: false, environmentId: null, organizationId: 1, createdAt: '2026-01-02T00:00:00Z', + }, +]; + +const dialogStub = { template: '
' }; + +function mountPanel(props: Record = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(WebhooksPanel, { + props: { orgId: 1, ...props }, + global: { plugins: [router], stubs: { 'v-dialog': dialogStub } }, + }); +} + +describe('WebhooksPanel', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + }); + + describe('WhenMountedWithOrgScope', () => { + it('ThenOrgWebhooksAreLoaded', async () => { + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks); + + mountPanel(); + await flushPromises(); + + expect(webhooksApi.listOrgWebhooks).toHaveBeenCalledWith(1); + }); + + it('ThenWebhooksTableIsRendered', async () => { + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks); + + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.find('[data-testid="webhooks-table"]').exists()).toBe(true); + expect(wrapper.text()).toContain('https://example.com/hook1'); + }); + + it('ThenNoWebhooksMessageIsShownWhenEmpty', async () => { + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]); + + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.find('[data-testid="no-webhooks-message"]').exists()).toBe(true); + }); + }); + + describe('WhenMountedWithEnvScope', () => { + it('ThenEnvWebhooksAreLoaded', async () => { + jest.mocked(webhooksApi.listEnvWebhooks).mockResolvedValue([]); + + mountPanel({ orgId: undefined, envApiKey: 'env-dev' }); + await flushPromises(); + + expect(webhooksApi.listEnvWebhooks).toHaveBeenCalledWith('env-dev'); + }); + }); + + describe('WhenEnabledToggleIsChanged', () => { + it('ThenUpdateOrgWebhookIsCalledWithToggledEnabled', async () => { + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks); + jest.mocked(webhooksApi.updateOrgWebhook).mockResolvedValue({ ...mockOrgWebhooks[0], enabled: false }); + + const wrapper = mountPanel(); + await flushPromises(); + + await (wrapper.findComponent(`[data-testid="webhook-enabled-toggle-1"]`) as VueWrapper).vm.$emit('update:modelValue', false); + await flushPromises(); + + expect(webhooksApi.updateOrgWebhook).toHaveBeenCalledWith( + 1, 1, + expect.objectContaining({ enabled: false }), + ); + }); + }); + + describe('WhenDeleteIsConfirmed', () => { + it('ThenDeleteOrgWebhookIsCalledAndWebhookIsRemoved', async () => { + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([...mockOrgWebhooks]); + jest.mocked(webhooksApi.deleteOrgWebhook).mockResolvedValue(undefined); + + const wrapper = mountPanel(); + await flushPromises(); + + await wrapper.find('[data-testid="delete-webhook-1"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="confirm-delete-webhook-btn"]').trigger('click'); + await flushPromises(); + + expect(webhooksApi.deleteOrgWebhook).toHaveBeenCalledWith(1, 1); + expect(wrapper.text()).not.toContain('https://example.com/hook1'); + }); + }); + + describe('WhenWebhookIsSaved', () => { + it('ThenNewWebhookIsAddedToList', async () => { + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]); + + const wrapper = mountPanel(); + await flushPromises(); + + const newHook: WebhookResponse = { ...mockOrgWebhooks[0], id: 99, url: 'https://new.example.com/hook' }; + await wrapper.findComponent({ name: 'WebhookDialog' }).vm.$emit('saved', newHook); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('https://new.example.com/hook'); + }); + + it('ThenExistingWebhookIsUpdatedInList', async () => { + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([...mockOrgWebhooks]); + + const wrapper = mountPanel(); + await flushPromises(); + + const updated: WebhookResponse = { ...mockOrgWebhooks[0], url: 'https://updated.example.com/hook' }; + await wrapper.findComponent({ name: 'WebhookDialog' }).vm.$emit('saved', updated); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('https://updated.example.com/hook'); + }); + }); +}); diff --git a/src/admin/tests/unit/views/AuditLogsView.spec.ts b/src/admin/tests/unit/views/AuditLogsView.spec.ts new file mode 100644 index 0000000..e98e7e8 --- /dev/null +++ b/src/admin/tests/unit/views/AuditLogsView.spec.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import AuditLogsView from '@/views/AuditLogsView.vue'; +import { useContextStore } from '@/stores/context'; +import type { ProjectResponse, AuditLogResponse } from '@/types/api'; + +jest.mock('@/api/auditLogs'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as auditLogsApi from '@/api/auditLogs'; + +const mockProject: ProjectResponse = { + id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z', +}; + +const mockLogs: AuditLogResponse[] = [ + { + id: 1, resourceType: 'Feature', resourceId: '42', action: 'Created', + changes: null, organizationId: 1, projectId: 10, environmentId: null, + actorUserId: 5, createdAt: '2026-01-10T10:00:00Z', + }, + { + id: 2, resourceType: 'FeatureState', resourceId: '99', action: 'Updated', + changes: '{"enabled":true}', organizationId: 1, projectId: 10, environmentId: 100, + actorUserId: 5, createdAt: '2026-01-11T11:00:00Z', + }, +]; + +function mountView() { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(AuditLogsView, { global: { plugins: [router] } }); +} + +describe('AuditLogsView', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + }); + + describe('WhenNoProjectIsSelected', () => { + it('ThenEmptyStateIsShown', () => { + const wrapper = mountView(); + expect(wrapper.find('[data-testid="audit-logs-table"]').exists()).toBe(false); + expect(wrapper.text()).toContain('Select a project'); + }); + }); + + describe('WhenProjectIsSelected', () => { + it('ThenAuditLogsAreLoaded', async () => { + jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockLogs, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + mountView(); + await flushPromises(); + + expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ page: 1 }), + ); + }); + + it('ThenAuditLogsTableIsRendered', async () => { + jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockLogs, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="audit-logs-table"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Feature'); + expect(wrapper.text()).toContain('Created'); + }); + }); + + describe('WhenResourceTypeFilterIsApplied', () => { + it('ThenLogsAreReloadedWithFilter', async () => { + jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({ + count: 1, next: null, previous: null, results: [mockLogs[0]], + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear(); + + await (wrapper.findComponent('[data-testid="filter-resource-type"]') as VueWrapper).vm.$emit('update:modelValue', 'Feature'); + await flushPromises(); + + expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ resourceType: 'Feature', page: 1 }), + ); + }); + }); + + describe('WhenActionFilterIsApplied', () => { + it('ThenLogsAreReloadedWithActionFilter', async () => { + jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({ + count: 1, next: null, previous: null, results: [mockLogs[0]], + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear(); + + await (wrapper.findComponent('[data-testid="filter-action"]') as VueWrapper).vm.$emit('update:modelValue', 'Created'); + await flushPromises(); + + expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ action: 'Created', page: 1 }), + ); + }); + }); + + describe('WhenDateRangeFilterIsApplied', () => { + it('ThenLogsAreReloadedWithDateRange', async () => { + jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({ + count: 1, next: null, previous: null, results: [mockLogs[0]], + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear(); + + await (wrapper.findComponent('[data-testid="filter-from"]') as VueWrapper).vm.$emit('update:modelValue', '2026-01-01'); + await flushPromises(); + + expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ from: '2026-01-01T00:00:00Z' }), + ); + }); + }); + + describe('WhenClearFiltersIsClicked', () => { + it('ThenFiltersAreResetAndLogsReloaded', async () => { + jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockLogs, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear(); + + await wrapper.find('[data-testid="clear-filters-btn"]').trigger('click'); + await flushPromises(); + + expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ resourceType: null, action: null, from: null, to: null }), + ); + }); + }); + + describe('WhenChangesExpandButtonIsClicked', () => { + it('ThenChangesContentIsDisplayed', async () => { + jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockLogs, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + // log id=2 has changes + await wrapper.find('[data-testid="expand-changes-2"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="changes-2"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="changes-2"]').text()).toContain('enabled'); + }); + }); +});