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
15 changes: 14 additions & 1 deletion src/Frontend/src/components/audit/AuditList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import { type default as Message, MessageStatus } from "@/resources/Message";
// ==================== Mock Setup ====================

vi.mock("@/composables/autoRefresh");
const auditingStatus = vi.hoisted(() => ({ value: "Available" }));
vi.mock("@/components/platformcapabilities/capabilities/AuditingCapability", () => ({
useAuditingCapability: () => ({ status: { value: "Available" } }),
useAuditingCapability: () => ({ status: auditingStatus }),
}));
vi.mock("@/components/platformcapabilities/wizards/AuditingWizardPages", () => ({
getAuditingWizardPages: () => [],
Expand Down Expand Up @@ -209,6 +210,7 @@ async function waitForFirstLoadToComplete() {
describe("FEATURE: Audit Messages Query State", () => {
beforeEach(() => {
vi.clearAllMocks();
auditingStatus.value = "Available";
localStorage.clear();
});

Expand Down Expand Up @@ -310,6 +312,17 @@ describe("FEATURE: Audit Messages Query State", () => {
});
});

describe("RULE: Onboarding prompts render only after the capability probe has answered", () => {
test("EXAMPLE: The banner appears once a completed probe found no messages", async () => {
auditingStatus.value = "Endpoints Not Configured";
await renderAuditList([]);

await waitForFirstLoadToComplete();

expect(document.querySelector("page-banner-stub")).not.toBeNull();
});
});

describe("RULE: A failed query tells the user what happened and what to try", () => {
test("EXAMPLE: The error banner is shown after a failed query", async () => {
const { store } = await renderAuditList([]);
Expand Down
9 changes: 6 additions & 3 deletions src/Frontend/src/components/audit/auditClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ class AuditClient {
);
}

public async hasSuccessfulMessages(): Promise<boolean> {
public async hasSuccessfulMessages(from?: Date): Promise<boolean> {
// Fetch the latest 10 messages and check if any are successful
// ideally we would want to filter successful messages server-side, but the API doesn't currently support that
const [, data] = await serviceControlClient.fetchTypedFromServiceControl<Message[]>(`messages2/?page_size=10&sort=time_sent&direction=desc`);
// ideally we would want to filter successful messages server-side, but the API doesn't currently support that.
// An unbounded sorted query scans the whole audit index, so callers should probe a bounded
// window first and only fall back to the unbounded form when the window is empty.
const bound = from ? `&from=${from.toISOString()}` : "";
const [, data] = await serviceControlClient.fetchTypedFromServiceControl<Message[]>(`messages2/?page_size=10&sort=time_sent&direction=desc${bound}`);
return data?.some((msg) => msg.status === MessageStatus.Successful) ?? false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ export function useAuditingCapability(): CapabilityComposable {
const auditIndicators = computed(() => {
const indicators: StatusIndicator[] = [];

// Messages available indicator - show if at least one instance is available
if (hasAvailableAuditInstances(auditInstances.value)) {
// Messages available indicator - show if at least one instance is available and the probe answered
if (hasAvailableAuditInstances(auditInstances.value) && hasSuccessfulMessages.value !== null) {
const messagesAvailable = isAllMessagesSupported.value && hasSuccessfulMessages.value;

let messageTooltip: string;
Expand Down
15 changes: 15 additions & 0 deletions src/Frontend/src/stores/AuditStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ describe("AuditStore refresh", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
localStorage.clear();
});

test("the default time range bounds the very first query", async () => {
fetchTypedFromServiceControl.mockResolvedValue([responseWithTotalCount(0), []]);
const store = useAuditStore();

await store.refresh();

const url = fetchTypedFromServiceControl.mock.calls[0][0] as string;
const params = new URLSearchParams(url.split("?")[1]);
expect(store.timeRangeFrom).toBe("now-6h");
expect(params.get("from")).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(params.get("to")).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(new Date(params.get("to")!).getTime() - new Date(params.get("from")!).getTime()).toBe(6 * 3600 * 1000);
});

test("a successful query populates the messages and total count", async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/Frontend/src/stores/AuditStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const useAuditStore = defineStore("AuditStore", () => {
);

if (activeQuery !== thisQuery) {
// a newer query took over the view state while this one was in flight
return;
}

Expand All @@ -77,7 +78,7 @@ export const useAuditStore = defineStore("AuditStore", () => {
queryFailed.value = false;
} catch {
if (thisQuery.signal.aborted) {
// Superseded by a newer query, which owns the view state from here on
// Superseded by a newer query, or the view was left
return;
}

Expand Down
81 changes: 81 additions & 0 deletions src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";

const auditClient = vi.hoisted(() => ({ hasSuccessfulMessages: vi.fn() }));
vi.mock("@/components/audit/auditClient", () => ({ default: auditClient }));
vi.mock("@/components/monitoring/monitoringClient", () => ({ default: { isMonitoringEnabled: false } }));
vi.mock("@/components/serviceControlClient", () => ({ default: { url: undefined } }));
vi.mock("@/composables/useConnectionsAndStatsAutoRefresh", () => ({
default: () => ({ store: { monitoringConnectionState: { unableToConnect: true } } }),
}));

import { usePlatformCapabilitiesStore } from "@/stores/PlatformCapabilitiesStore";

describe("FEATURE: Successful-messages capability probe", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
localStorage.clear();
});

test("EXAMPLE: Before the first probe answers, the state is unknown, not 'no messages'", () => {
const store = usePlatformCapabilitiesStore();

expect(store.hasSuccessfulMessages).toBeNull();
});

test("EXAMPLE: A failed probe proves nothing — the state stays unknown and is retried", async () => {
auditClient.hasSuccessfulMessages.mockRejectedValue(new Error("boom"));
const store = usePlatformCapabilitiesStore();

await store.refresh();
expect(store.hasSuccessfulMessages).toBeNull();

auditClient.hasSuccessfulMessages.mockReset();
auditClient.hasSuccessfulMessages.mockResolvedValue(true);
await store.refresh();
expect(store.hasSuccessfulMessages).toBe(true);
});

test("EXAMPLE: A completed probe finding nothing reports false", async () => {
auditClient.hasSuccessfulMessages.mockResolvedValue(false);
const store = usePlatformCapabilitiesStore();

await store.refresh();

expect(store.hasSuccessfulMessages).toBe(false);
});

test("EXAMPLE: A bounded window is probed before falling back to the full store", async () => {
auditClient.hasSuccessfulMessages.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const store = usePlatformCapabilitiesStore();

await store.refresh();

expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(2);
expect(auditClient.hasSuccessfulMessages.mock.calls[0][0]).toBeInstanceOf(Date); // bounded first
expect(auditClient.hasSuccessfulMessages.mock.calls[1][0]).toBeUndefined(); // unbounded fallback
expect(store.hasSuccessfulMessages).toBe(true);
});

test("EXAMPLE: A hit in the bounded window skips the expensive unbounded probe", async () => {
auditClient.hasSuccessfulMessages.mockResolvedValueOnce(true);
const store = usePlatformCapabilitiesStore();

await store.refresh();

expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(1);
expect(store.hasSuccessfulMessages).toBe(true);
});

test("EXAMPLE: Once successful messages were seen, later refreshes stop probing", async () => {
auditClient.hasSuccessfulMessages.mockResolvedValue(true);
const store = usePlatformCapabilitiesStore();

await store.refresh();
await store.refresh();
await store.refresh();

expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(1);
});
});
16 changes: 13 additions & 3 deletions src/Frontend/src/stores/PlatformCapabilitiesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,24 @@ function saveVisibility(visibility: PlatformCapabilitiesVisibility): void {
export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesStore", () => {
const { store: connectionStore } = useConnectionsAndStatsAutoRefresh();
const visibility = ref<PlatformCapabilitiesVisibility>(loadVisibility());
const hasSuccessfulMessages = ref(false);
// null = not yet determined: consumers must render nothing promotional until the probe answers
const hasSuccessfulMessages = ref<boolean | null>(null);
const hasMonitoredEndpoints = ref(false);

async function checkForSuccessfulMessages() {
// Successful messages don't un-exist: once seen, never query again.
// The unbounded form of this probe scans the entire audit index (minutes on large
// stores), so probe a recent window first and fall back to unbounded only when
// the window is empty.
if (hasSuccessfulMessages.value === true) {
return;
}
try {
hasSuccessfulMessages.value = await auditClient.hasSuccessfulMessages();
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000);
hasSuccessfulMessages.value = (await auditClient.hasSuccessfulMessages(sevenDaysAgo)) || (await auditClient.hasSuccessfulMessages());
} catch {
hasSuccessfulMessages.value = false;
// A failed probe proves nothing about the data; stay unknown and let the next tick retry
hasSuccessfulMessages.value = null;
}
}

Expand Down