Adding Audit and Webhooks

This commit is contained in:
2026-04-13 19:43:58 -07:00
parent fc62ea634a
commit 4195d384d0
11 changed files with 1589 additions and 17 deletions

View File

@@ -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<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/organisations/${orgId}/audit-logs`,
{ params: toParams(filter) },
);
return data;
}
export async function listAuditLogsByProject(
projectId: number,
filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/projects/${projectId}/audit-logs`,
{ params: toParams(filter) },
);
return data;
}

View File

@@ -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<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisations/${orgId}/webhooks`);
return data;
}
export async function createOrgWebhook(
orgId: number,
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>(
`/v1/organisations/${orgId}/webhooks`,
request,
);
return data;
}
export async function updateOrgWebhook(
orgId: number,
webhookId: number,
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>(
`/v1/organisations/${orgId}/webhooks/${webhookId}`,
request,
);
return data;
}
export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${orgId}/webhooks/${webhookId}`);
}
// ─── Environment webhooks ─────────────────────────────────────────────────────
export async function listEnvWebhooks(envApiKey: string): Promise<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>(
`/v1/environments/${envApiKey}/webhooks`,
);
return data;
}
export async function createEnvWebhook(
envApiKey: string,
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>(
`/v1/environments/${envApiKey}/webhooks`,
request,
);
return data;
}
export async function updateEnvWebhook(
envApiKey: string,
webhookId: number,
request: CreateWebhookRequest,
): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>(
`/v1/environments/${envApiKey}/webhooks/${webhookId}`,
request,
);
return data;
}
export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/environments/${envApiKey}/webhooks/${webhookId}`);
}
// ─── Delivery logs ────────────────────────────────────────────────────────────
export async function listWebhookDeliveries(
envApiKey: string,
webhookId: number,
): Promise<WebhookDeliveryLogResponse[]> {
const { data } = await apiClient.get<WebhookDeliveryLogResponse[]>(
`/v1/environments/${envApiKey}/webhooks/${webhookId}/deliveries`,
);
return data;
}

View File

@@ -0,0 +1,95 @@
<template>
<div data-testid="webhook-delivery-history">
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<div v-if="isLoading" class="d-flex justify-center py-6">
<v-progress-circular indeterminate color="primary" />
</div>
<template v-else>
<div v-if="deliveries.length === 0" class="text-center py-6 text-medium-emphasis text-body-2" data-testid="no-deliveries-message">
No delivery history for this webhook.
</div>
<v-table v-else density="compact" data-testid="deliveries-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Event</th>
<th style="width: 100px">Status</th>
<th style="width: 80px">Attempt</th>
<th style="width: 100px">HTTP Code</th>
</tr>
</thead>
<tbody>
<tr v-for="delivery in deliveries" :key="delivery.id" :data-testid="`delivery-row-${delivery.id}`">
<td class="text-caption">{{ formatDate(delivery.attemptedAt) }}</td>
<td class="text-body-2">{{ delivery.eventType }}</td>
<td>
<v-chip
:color="delivery.success ? 'success' : 'error'"
size="x-small"
variant="tonal"
:data-testid="`delivery-status-${delivery.id}`"
>
{{ delivery.success ? 'Success' : 'Failed' }}
</v-chip>
</td>
<td class="text-body-2 text-medium-emphasis">{{ delivery.attemptNumber }}</td>
<td class="text-body-2 text-medium-emphasis">{{ delivery.responseStatusCode ?? '—' }}</td>
</tr>
</tbody>
</v-table>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { listWebhookDeliveries } from '@/api/webhooks';
import type { WebhookDeliveryLogResponse } from '@/types/api';
interface Props {
envApiKey: string;
webhookId: number;
}
const props = defineProps<Props>();
const deliveries = ref<WebhookDeliveryLogResponse[]>([]);
const isLoading = ref(false);
const errorMessage = ref<string | null>(null);
async function loadDeliveries(): Promise<void> {
isLoading.value = true;
errorMessage.value = null;
try {
deliveries.value = await listWebhookDeliveries(props.envApiKey, props.webhookId);
} catch {
deliveries.value = [];
errorMessage.value = 'Failed to load delivery history. Please try again.';
} finally {
isLoading.value = false;
}
}
watch(() => props.webhookId, loadDeliveries);
onMounted(loadDeliveries);
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="500"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card rounded="lg" data-testid="webhook-dialog">
<v-card-title class="d-flex align-center justify-space-between pa-6 pb-3">
<span class="text-h6">{{ webhook ? 'Edit Webhook' : 'Add Webhook' }}</span>
<v-btn icon="mdi-close" variant="text" size="small" @click="$emit('update:modelValue', false)" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<v-form ref="formRef" data-testid="webhook-form">
<v-text-field
v-model="form.url"
label="Endpoint URL"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.url]"
placeholder="https://example.com/webhook"
data-testid="webhook-url-input"
/>
<v-text-field
v-model="form.secret"
label="Secret (optional)"
variant="outlined"
density="comfortable"
class="mb-3"
:type="showSecret ? 'text' : 'password'"
:append-inner-icon="showSecret ? 'mdi-eye-off' : 'mdi-eye'"
hint="Used to sign the webhook payload"
data-testid="webhook-secret-input"
@click:append-inner="showSecret = !showSecret"
/>
<div class="d-flex align-center justify-space-between">
<div>
<p class="text-body-2 font-weight-medium mb-0">Enabled</p>
<p class="text-caption text-medium-emphasis">Deliver events to this endpoint</p>
</div>
<v-switch
v-model="form.enabled"
color="primary"
hide-details
data-testid="webhook-enabled-toggle"
/>
</div>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="isSaving"
data-testid="save-webhook-btn"
@click="onSave"
>
{{ webhook ? 'Save' : 'Add' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { createOrgWebhook, updateOrgWebhook, createEnvWebhook, updateEnvWebhook } from '@/api/webhooks';
import type { WebhookResponse } from '@/types/api';
interface Props {
modelValue: boolean;
webhook?: WebhookResponse | null;
orgId?: number;
envApiKey?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [webhook: WebhookResponse];
}>();
const formRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const isSaving = ref(false);
const errorMessage = ref<string | null>(null);
const showSecret = ref(false);
const form = ref({ url: '', secret: '', enabled: true });
const rules = {
required: (v: string) => !!v || 'Required',
url: (v: string) => {
try { new URL(v); return true; } catch { return 'Must be a valid URL'; }
},
};
function resetForm(): void {
form.value = {
url: props.webhook?.url ?? '',
secret: props.webhook?.secret ?? '',
enabled: props.webhook?.enabled ?? true,
};
errorMessage.value = null;
showSecret.value = false;
}
watch(() => props.modelValue, (open) => {
if (open) resetForm();
});
watch(() => props.webhook, resetForm, { immediate: true });
async function onSave(): Promise<void> {
if (!formRef.value) return;
const { valid } = await formRef.value.validate();
if (!valid) return;
isSaving.value = true;
errorMessage.value = null;
const request = {
url: form.value.url,
secret: form.value.secret || null,
enabled: form.value.enabled,
};
try {
let saved: WebhookResponse;
if (props.webhook) {
if (props.orgId !== undefined) {
saved = await updateOrgWebhook(props.orgId, props.webhook.id, request);
} else if (props.envApiKey) {
saved = await updateEnvWebhook(props.envApiKey, props.webhook.id, request);
} else {
throw new Error('Missing scope');
}
} else {
if (props.orgId !== undefined) {
saved = await createOrgWebhook(props.orgId, request);
} else if (props.envApiKey) {
saved = await createEnvWebhook(props.envApiKey, request);
} else {
throw new Error('Missing scope');
}
}
emit('saved', saved);
emit('update:modelValue', false);
} catch {
errorMessage.value = 'Failed to save webhook. Please try again.';
} finally {
isSaving.value = false;
}
}
</script>

View File

@@ -0,0 +1,283 @@
<template>
<div data-testid="webhooks-panel">
<div class="d-flex align-center justify-space-between mb-4">
<p class="text-body-1 font-weight-medium mb-0">Webhooks</p>
<v-btn
size="small"
color="primary"
variant="tonal"
prepend-icon="mdi-plus"
data-testid="add-webhook-btn"
@click="openAddDialog"
>
Add Webhook
</v-btn>
</div>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<div v-if="isLoading" class="d-flex justify-center py-6">
<v-progress-circular indeterminate color="primary" />
</div>
<template v-else>
<div
v-if="webhooks.length === 0"
class="text-center py-6 text-medium-emphasis text-body-2"
data-testid="no-webhooks-message"
>
No webhooks configured. Add one to start receiving events.
</div>
<v-table v-else density="compact" data-testid="webhooks-table">
<thead>
<tr>
<th>URL</th>
<th style="width: 80px">Enabled</th>
<th style="width: 120px">Created</th>
<th style="width: 120px" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="hook in webhooks" :key="hook.id" :data-testid="`webhook-row-${hook.id}`">
<td>
<span class="text-body-2 font-weight-medium" style="word-break: break-all">
{{ hook.url }}
</span>
</td>
<td>
<v-switch
:model-value="hook.enabled"
color="primary"
hide-details
density="compact"
:loading="togglingId === hook.id"
:data-testid="`webhook-enabled-toggle-${hook.id}`"
@update:model-value="onToggleEnabled(hook, $event)"
/>
</td>
<td class="text-caption text-medium-emphasis">{{ formatDate(hook.createdAt) }}</td>
<td class="text-end">
<v-btn
icon="mdi-history"
size="x-small"
variant="text"
:data-testid="`view-deliveries-${hook.id}`"
@click="openDeliveries(hook)"
/>
<v-btn
icon="mdi-pencil-outline"
size="x-small"
variant="text"
:data-testid="`edit-webhook-${hook.id}`"
@click="openEditDialog(hook)"
/>
<v-btn
icon="mdi-trash-can-outline"
size="x-small"
variant="text"
color="error"
:data-testid="`delete-webhook-${hook.id}`"
@click="openDeleteConfirm(hook)"
/>
</td>
</tr>
</tbody>
</v-table>
</template>
<!-- Add / Edit dialog -->
<WebhookDialog
v-model="showDialog"
:webhook="editingWebhook"
:org-id="orgId"
:env-api-key="envApiKey"
@saved="onWebhookSaved"
/>
<!-- Delivery history dialog -->
<v-dialog v-model="showDeliveries" max-width="700">
<v-card rounded="lg" data-testid="deliveries-dialog">
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-2">
<span class="text-h6">Delivery History</span>
<v-btn icon="mdi-close" variant="text" size="small" @click="showDeliveries = false" />
</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<WebhookDeliveryHistory
v-if="deliveryWebhook && envApiKey"
:env-api-key="envApiKey"
:webhook-id="deliveryWebhook.id"
/>
<p v-else class="text-body-2 text-medium-emphasis">
Delivery history is only available for environment-scoped webhooks.
</p>
</v-card-text>
</v-card>
</v-dialog>
<!-- Delete confirmation dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card rounded="lg" data-testid="delete-webhook-dialog">
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Delete Webhook</v-card-title>
<v-card-text class="pa-4 pt-0">
<p class="text-body-2">
Are you sure you want to delete the webhook for
<strong>{{ deletingWebhook?.url }}</strong>?
</p>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn
color="error"
variant="flat"
:loading="isDeleting"
data-testid="confirm-delete-webhook-btn"
@click="onDeleteWebhook"
>
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {
listOrgWebhooks, createOrgWebhook, updateOrgWebhook, deleteOrgWebhook,
listEnvWebhooks, updateEnvWebhook, deleteEnvWebhook,
} from '@/api/webhooks';
import WebhookDialog from './WebhookDialog.vue';
import WebhookDeliveryHistory from './WebhookDeliveryHistory.vue';
import type { WebhookResponse } from '@/types/api';
interface Props {
orgId?: number;
envApiKey?: string;
}
const props = defineProps<Props>();
const webhooks = ref<WebhookResponse[]>([]);
const isLoading = ref(false);
const errorMessage = ref<string | null>(null);
const togglingId = ref<number | null>(null);
const showDialog = ref(false);
const editingWebhook = ref<WebhookResponse | null>(null);
const showDeliveries = ref(false);
const deliveryWebhook = ref<WebhookResponse | null>(null);
const showDeleteDialog = ref(false);
const deletingWebhook = ref<WebhookResponse | null>(null);
const isDeleting = ref(false);
async function loadWebhooks(): Promise<void> {
isLoading.value = true;
errorMessage.value = null;
try {
if (props.envApiKey) {
webhooks.value = await listEnvWebhooks(props.envApiKey);
} else if (props.orgId !== undefined) {
webhooks.value = await listOrgWebhooks(props.orgId);
}
} catch {
webhooks.value = [];
errorMessage.value = 'Failed to load webhooks. Please try again.';
} finally {
isLoading.value = false;
}
}
onMounted(loadWebhooks);
function openAddDialog(): void {
editingWebhook.value = null;
showDialog.value = true;
}
function openEditDialog(hook: WebhookResponse): void {
editingWebhook.value = hook;
showDialog.value = true;
}
function openDeliveries(hook: WebhookResponse): void {
deliveryWebhook.value = hook;
showDeliveries.value = true;
}
function openDeleteConfirm(hook: WebhookResponse): void {
deletingWebhook.value = hook;
showDeleteDialog.value = true;
}
async function onToggleEnabled(hook: WebhookResponse, enabled: boolean | null): Promise<void> {
if (enabled === null) return;
togglingId.value = hook.id;
errorMessage.value = null;
try {
const request = { url: hook.url, secret: hook.secret, enabled };
let updated: WebhookResponse;
if (props.envApiKey) {
updated = await updateEnvWebhook(props.envApiKey, hook.id, request);
} else if (props.orgId !== undefined) {
updated = await updateOrgWebhook(props.orgId, hook.id, request);
} else {
return;
}
const idx = webhooks.value.findIndex((w) => w.id === hook.id);
if (idx >= 0) webhooks.value[idx] = updated;
} catch {
errorMessage.value = 'Failed to update webhook. Please try again.';
} finally {
togglingId.value = null;
}
}
function onWebhookSaved(saved: WebhookResponse): void {
const idx = webhooks.value.findIndex((w) => w.id === saved.id);
if (idx >= 0) {
webhooks.value[idx] = saved;
} else {
webhooks.value.push(saved);
}
}
async function onDeleteWebhook(): Promise<void> {
if (!deletingWebhook.value) return;
isDeleting.value = true;
errorMessage.value = null;
try {
if (props.envApiKey) {
await deleteEnvWebhook(props.envApiKey, deletingWebhook.value.id);
} else if (props.orgId !== undefined) {
await deleteOrgWebhook(props.orgId, deletingWebhook.value.id);
}
webhooks.value = webhooks.value.filter((w) => w.id !== deletingWebhook.value!.id);
showDeleteDialog.value = false;
deletingWebhook.value = null;
} catch {
errorMessage.value = 'Failed to delete webhook. Please try again.';
} finally {
isDeleting.value = false;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>

View File

@@ -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 ───────────────────────────────────────────────────────────────

View File

@@ -1,18 +1,303 @@
<template>
<v-row>
<v-col cols="12">
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Audit Logs</h1>
</div>
<v-card>
<v-card-text class="text-medium-emphasis">
Audit logs record all changes made to your feature flags, segments, and environments.
Select a project to view audit logs.
</v-card-text>
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Audit Logs</h1>
</div>
<!-- No project selected -->
<v-card v-if="!contextStore.currentProject" variant="outlined" rounded="lg">
<v-card-text class="text-center py-10">
<v-icon size="48" color="medium-emphasis" class="mb-3">mdi-clipboard-text-clock-outline</v-icon>
<p class="text-h6 mb-2">Select a project</p>
<p class="text-body-2 text-medium-emphasis">
Choose a project from the sidebar to view its audit logs.
</p>
</v-card-text>
</v-card>
<template v-else>
<!-- Filters -->
<v-card variant="outlined" rounded="lg" class="mb-4 pa-4">
<div class="d-flex align-center ga-3 flex-wrap">
<v-select
v-model="filter.resourceType"
:items="resourceTypeOptions"
label="Resource type"
variant="outlined"
density="compact"
hide-details
clearable
style="min-width: 160px; flex: 1"
data-testid="filter-resource-type"
@update:model-value="onFilterChange"
/>
<v-select
v-model="filter.action"
:items="actionOptions"
label="Action"
variant="outlined"
density="compact"
hide-details
clearable
style="min-width: 140px; flex: 1"
data-testid="filter-action"
@update:model-value="onFilterChange"
/>
<v-text-field
v-model="filter.from"
label="From date"
variant="outlined"
density="compact"
hide-details
type="date"
style="min-width: 160px; flex: 1"
data-testid="filter-from"
@update:model-value="onFilterChange"
/>
<v-text-field
v-model="filter.to"
label="To date"
variant="outlined"
density="compact"
hide-details
type="date"
style="min-width: 160px; flex: 1"
data-testid="filter-to"
@update:model-value="onFilterChange"
/>
<v-btn
variant="text"
size="small"
data-testid="clear-filters-btn"
@click="clearFilters"
>
Clear
</v-btn>
</div>
</v-card>
</v-col>
</v-row>
<!-- Table -->
<v-card rounded="lg">
<v-data-table
:headers="headers"
:items="logs"
:loading="isLoading"
:items-per-page="pageSize"
hover
data-testid="audit-logs-table"
>
<!-- Timestamp -->
<template #item.createdAt="{ item }: { item: AuditLogResponse }">
<span class="text-caption text-medium-emphasis">{{ formatDate(item.createdAt) }}</span>
</template>
<!-- Action chip -->
<template #item.action="{ item }: { item: AuditLogResponse }">
<v-chip
:color="actionColor(item.action)"
size="x-small"
variant="tonal"
>
{{ item.action }}
</v-chip>
</template>
<!-- Resource type -->
<template #item.resourceType="{ item }: { item: AuditLogResponse }">
<span class="text-body-2">{{ item.resourceType }}</span>
</template>
<!-- Resource ID -->
<template #item.resourceId="{ item }: { item: AuditLogResponse }">
<span class="text-body-2 text-medium-emphasis">{{ item.resourceId }}</span>
</template>
<!-- Changes (expandable) -->
<template #item.changes="{ item }: { item: AuditLogResponse }">
<div v-if="item.changes">
<v-btn
size="x-small"
variant="text"
:data-testid="`expand-changes-${item.id}`"
@click="toggleChanges(item.id)"
>
{{ expandedIds.has(item.id) ? 'Hide' : 'Show' }}
</v-btn>
<pre
v-if="expandedIds.has(item.id)"
class="text-caption mt-1 pa-2 rounded"
style="background: rgba(0,0,0,0.05); white-space: pre-wrap; word-break: break-all; max-width: 300px"
:data-testid="`changes-${item.id}`"
>{{ formatJson(item.changes) }}</pre>
</div>
<span v-else class="text-caption text-medium-emphasis"></span>
</template>
<!-- Empty state -->
<template #no-data>
<div class="text-center py-8">
<v-icon size="40" color="medium-emphasis" class="mb-2">mdi-clipboard-text-clock-outline</v-icon>
<p class="text-body-2 text-medium-emphasis">No audit log entries found.</p>
</div>
</template>
</v-data-table>
<!-- Manual pagination controls -->
<div class="d-flex align-center justify-end pa-3 ga-2">
<span class="text-caption text-medium-emphasis">
Page {{ page }} of {{ totalPages }} ({{ total }} entries)
</span>
<v-btn
icon="mdi-chevron-left"
size="small"
variant="text"
:disabled="page <= 1 || isLoading"
data-testid="prev-page-btn"
@click="prevPage"
/>
<v-btn
icon="mdi-chevron-right"
size="small"
variant="text"
:disabled="page >= totalPages || isLoading"
data-testid="next-page-btn"
@click="nextPage"
/>
</div>
</v-card>
</template>
<!-- Error snackbar -->
<v-snackbar
v-model="showErrorSnackbar"
color="error"
:timeout="4000"
location="bottom"
>
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="showErrorSnackbar = false">Dismiss</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { listAuditLogsByProject } from '@/api/auditLogs';
import { useContextStore } from '@/stores/context';
import type { AuditLogResponse } from '@/types/api';
const contextStore = useContextStore();
const logs = ref<AuditLogResponse[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 20;
const isLoading = ref(false);
const expandedIds = ref(new Set<number>());
const showErrorSnackbar = ref(false);
const snackbarMessage = ref('');
const filter = ref<{
resourceType: string | null;
action: string | null;
from: string | null;
to: string | null;
}>({ resourceType: null, action: null, from: null, to: null });
const resourceTypeOptions = [
'Feature', 'FeatureState', 'Segment', 'Environment', 'Project',
'Organization', 'Identity', 'ApiKey', 'Webhook',
];
const actionOptions = ['Created', 'Updated', 'Deleted', 'Enabled', 'Disabled'];
const headers = [
{ title: 'Timestamp', key: 'createdAt', sortable: false, width: '160' },
{ title: 'Action', key: 'action', sortable: false, width: '100' },
{ title: 'Resource Type', key: 'resourceType', sortable: false, width: '140' },
{ title: 'Resource ID', key: 'resourceId', sortable: false, width: '100' },
{ title: 'Changes', key: 'changes', sortable: false },
];
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function showError(message: string): void {
snackbarMessage.value = message;
showErrorSnackbar.value = true;
}
async function loadLogs(): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) {
logs.value = [];
total.value = 0;
return;
}
isLoading.value = true;
try {
const result = await listAuditLogsByProject(projectId, {
resourceType: filter.value.resourceType,
action: filter.value.action,
from: filter.value.from ? `${filter.value.from}T00:00:00Z` : null,
to: filter.value.to ? `${filter.value.to}T23:59:59Z` : null,
page: page.value,
pageSize,
});
logs.value = result.results;
total.value = result.count;
} catch {
logs.value = [];
total.value = 0;
showError('Failed to load audit logs. Please refresh the page.');
} finally {
isLoading.value = false;
}
}
watch(() => contextStore.currentProject, () => {
page.value = 1;
loadLogs();
}, { immediate: true });
function onFilterChange(): void {
page.value = 1;
loadLogs();
}
function clearFilters(): void {
filter.value = { resourceType: null, action: null, from: null, to: null };
page.value = 1;
loadLogs();
}
function prevPage(): void {
if (page.value > 1) { page.value--; loadLogs(); }
}
function nextPage(): void {
if (page.value < totalPages.value) { page.value++; loadLogs(); }
}
function toggleChanges(id: number): void {
const next = new Set(expandedIds.value);
if (next.has(id)) { next.delete(id); } else { next.add(id); }
expandedIds.value = next;
}
function formatJson(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function actionColor(action: string): string {
if (action === 'Created') return 'success';
if (action === 'Deleted') return 'error';
return 'primary';
}
</script>

View File

@@ -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<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
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
});
});
});

View File

@@ -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: '<div><slot /></div>' };
function mountDialog(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
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<any>).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<any>).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<any>;
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<any>).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' }),
);
});
});
});

View File

@@ -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: '<div><slot /></div>' };
function mountPanel(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
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<any>).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');
});
});
});

View File

@@ -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: '<div />' } }] });
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<any>).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<any>).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<any>).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');
});
});
});