diff --git a/app/components/analysis/AnalysesTable.vue b/app/components/analysis/AnalysesTable.vue index af6741a..e7c9c2e 100644 --- a/app/components/analysis/AnalysesTable.vue +++ b/app/components/analysis/AnalysesTable.vue @@ -1,6 +1,5 @@ - - - - diff --git a/app/components/projects/ProjectsTable.vue b/app/components/projects/ProjectsTable.vue new file mode 100644 index 0000000..9c35a66 --- /dev/null +++ b/app/components/projects/ProjectsTable.vue @@ -0,0 +1,400 @@ + + + + + diff --git a/app/components/shared/DataStoreBadge.vue b/app/components/shared/DataStoreBadge.vue new file mode 100644 index 0000000..dea1284 --- /dev/null +++ b/app/components/shared/DataStoreBadge.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/app/components/table/ApproveRejectToggle.vue b/app/components/table/ApproveRejectToggle.vue deleted file mode 100644 index ca83d27..0000000 --- a/app/components/table/ApproveRejectToggle.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - - - diff --git a/app/composables/useProjectAnalysisSummary.ts b/app/composables/useProjectAnalysisSummary.ts new file mode 100644 index 0000000..0b66c0f --- /dev/null +++ b/app/composables/useProjectAnalysisSummary.ts @@ -0,0 +1,176 @@ +import { ref } from "vue"; +import { useNuxtApp } from "nuxt/app"; +import { type AnalysisNode, type ListRoutes, type PodProgressResponse, PodStatus, type Route } from "~/services/Api"; +import { parseKongTags } from "~/utils/parse-kong-tags"; +import { + emptyProjectAnalysisSummary, + type ProjectAnalysisSummary, + summariseProjectAnalyses +} from "~/utils/summarise-project-analyses"; + +export type HubFetch = ( + url: string, + opts?: Record, +) => Promise; + +const PAGE_LIMIT = 50; +const MAX_PAGES = 40; + +const FINISHED_STATUSES: Array = [ + PodStatus.Failed, + PodStatus.Executed, + PodStatus.Stopped, +]; + +export interface AnalysisNodeFetchResult { + nodes: AnalysisNode[]; + truncated: boolean; + incomplete: boolean; +} + +export async function fetchAllAnalysisNodes( + hubApi: HubFetch, +): Promise { + const allNodes: AnalysisNode[] = []; + let truncated = false; + let incomplete = false; + + for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex++) { + let nextPage: AnalysisNode[] | undefined; + try { + nextPage = (await hubApi("/analysis-nodes", { + method: "GET", + query: { + include: "analysis", + sort: "-updated_at", + page: { offset: pageIndex * PAGE_LIMIT, limit: PAGE_LIMIT }, + }, + })) as AnalysisNode[]; + } catch { + // To avoid it thinking it's the end of the result set + incomplete = true; + break; + } + + if (!nextPage || nextPage.length === 0) break; + allNodes.push(...nextPage); + if (nextPage.length < PAGE_LIMIT) break; + // Full set = yet more results to fetch + if (pageIndex === MAX_PAGES - 1) truncated = true; + } + + return { nodes: allNodes, truncated, incomplete }; +} + +export interface DataStoreProjectIdsResult { + projectIds: Set; + unavailable: boolean; +} + +export async function fetchDataStoreProjectIds( + hubApi: HubFetch, +): Promise { + let unavailable = false; + const routesResp = (await hubApi("/kong/project", { method: "GET" }).catch( + () => { + unavailable = true; + return undefined; + }, + )) as ListRoutes | undefined; + + const projectIds = new Set(); + routesResp?.data?.forEach((route: Route) => { + const projectId = parseKongTags(route.tags).project; + if (projectId) projectIds.add(projectId); + }); + return { projectIds, unavailable }; +} + +export async function fetchExecutionStatuses( + hubApi: HubFetch, +): Promise { + return (await hubApi("/po/status", { method: "GET" }).catch( + () => undefined, + )) as PodProgressResponse | undefined; +} + +export function mergeExecutionStatuses( + analysisNodes: AnalysisNode[], + executionStatuses: PodProgressResponse | undefined, +): AnalysisNode[] { + const orchestratorReachable = executionStatuses !== undefined; + + return analysisNodes.map((analysisNode) => { + const merged = { ...analysisNode }; + const analysisId = merged.analysis_id; + + if (executionStatuses && analysisId in executionStatuses) { + merged.execution_status = executionStatuses[analysisId]! + .status as AnalysisNode["execution_status"]; + } else if ( + orchestratorReachable && + !FINISHED_STATUSES.includes(merged.execution_status as PodStatus) + ) { + merged.execution_status = null; + } + + return merged; + }); +} + +export function useProjectAnalysisSummary() { + const summaries = ref>(new Map()); + const dataStoreProjectIds = ref>(new Set()); + const loading = ref(false); + const truncated = ref(false); + const incomplete = ref(false); + + // Kong could not be reached, so dataStoreProjectIds is not reliable + const dataStoreUnavailable = ref(false); + + async function refreshSummaries() { + loading.value = true; + try { + const hubApi = useNuxtApp().$hubApi as unknown as HubFetch; + const [analysisNodeResult, dataStoreResult, executionStatuses] = + await Promise.all([ + fetchAllAnalysisNodes(hubApi), + fetchDataStoreProjectIds(hubApi), + fetchExecutionStatuses(hubApi), + ]); + + dataStoreProjectIds.value = dataStoreResult.projectIds; + dataStoreUnavailable.value = dataStoreResult.unavailable; + truncated.value = analysisNodeResult.truncated; + incomplete.value = analysisNodeResult.incomplete; + summaries.value = summariseProjectAnalyses( + mergeExecutionStatuses(analysisNodeResult.nodes, executionStatuses), + dataStoreResult.projectIds, + ); + } finally { + loading.value = false; + } + } + + // Handle projects with no analyses or missing proj ID + function summaryFor( + projectId: string | undefined | null, + ): ProjectAnalysisSummary { + if (!projectId) return emptyProjectAnalysisSummary(false); + return ( + summaries.value.get(projectId) ?? + emptyProjectAnalysisSummary(dataStoreProjectIds.value.has(projectId)) + ); + } + + return { + summaries, + dataStoreProjectIds, + loading, + truncated, + incomplete, + dataStoreUnavailable, + refreshSummaries, + summaryFor, + }; +} diff --git a/app/pages/projects.vue b/app/pages/projects.vue index 94c95b5..757ecdd 100644 --- a/app/pages/projects.vue +++ b/app/pages/projects.vue @@ -1,9 +1,9 @@ diff --git a/app/utils/summarise-project-analyses.ts b/app/utils/summarise-project-analyses.ts new file mode 100644 index 0000000..18791cf --- /dev/null +++ b/app/utils/summarise-project-analyses.ts @@ -0,0 +1,169 @@ +import { type AnalysisNode, PodStatus } from "~/services/Api"; +import { ApprovalStatus } from "~/types/node"; +import { ProcessStatus } from "~/types/analysis"; + +export interface ProjectAnalysisSummary { + total: number; + executed: number; + running: number; + failed: number; + stopped: number; + waiting: number; + idle: number; + hasDataStore: boolean; +} + +export function emptyProjectAnalysisSummary( + hasDataStore: boolean, +): ProjectAnalysisSummary { + return { + total: 0, + executed: 0, + running: 0, + failed: 0, + stopped: 0, + waiting: 0, + idle: 0, + hasDataStore, + }; +} + +// An analysis is still waiting to be approved, built or distributed +function isWaitingOnHub(analysisNode: AnalysisNode): boolean { + if (analysisNode.approval_status !== ApprovalStatus.Approved) return true; + if (analysisNode.analysis?.build_status !== ProcessStatus.Executed) + return true; + return analysisNode.analysis?.distribution_status !== ProcessStatus.Executed; +} + +export function summariseProjectAnalyses( + analysisNodes: AnalysisNode[], + dataStoreProjectIds: Set, +): Map { + const summaries = new Map(); + + for (const analysisNode of analysisNodes) { + const projectId = analysisNode.analysis?.project_id; + if (!projectId) continue; + + let summary = summaries.get(projectId); + if (!summary) { + summary = emptyProjectAnalysisSummary(dataStoreProjectIds.has(projectId)); + summaries.set(projectId, summary); + } + summary.total++; + + switch (analysisNode.execution_status) { + case PodStatus.Failed: + summary.failed++; + break; + + case PodStatus.Executed: + summary.executed++; + break; + + case PodStatus.Starting: + case PodStatus.Started: + case PodStatus.Executing: + summary.running++; + break; + + case PodStatus.Stopping: + case PodStatus.Stopped: + summary.stopped++; + break; + + default: + if (isWaitingOnHub(analysisNode)) { + summary.waiting++; + } else { + summary.idle++; + } + } + } + + return summaries; +} + +export type ProjectStatusKey = + | "noDataStore" + | "failed" + | "waiting" + | "running" + | "stopped" + | "idle" + | "complete" + | "noAnalyses"; + +export type ProjectStatusSeverity = + | "danger" + | "warn" + | "info" + | "success" + | "secondary"; + +export interface ProjectStatus { + key: ProjectStatusKey; + label: string; + severity: ProjectStatusSeverity; + rank: number; +} + +// Severity ordering, 1 is top priority (most egregious and requires attention) +const STATUS_RANK: Record = { + noDataStore: 1, + failed: 2, + waiting: 3, + running: 4, + stopped: 5, + idle: 6, + complete: 7, + noAnalyses: 8, +}; + +const STATUS_SEVERITY: Record = { + noDataStore: "danger", + failed: "danger", + waiting: "warn", + running: "info", + stopped: "warn", + idle: "secondary", + complete: "success", + noAnalyses: "secondary", +}; + +function status(key: ProjectStatusKey, label: string): ProjectStatus { + return { key, label, severity: STATUS_SEVERITY[key], rank: STATUS_RANK[key] }; +} + +export function deriveProjectStatus( + summary: ProjectAnalysisSummary, + requireDataStore: boolean, +): ProjectStatus { + if (summary.total === 0) return status("noAnalyses", "No analyses"); + if (!summary.hasDataStore && requireDataStore) + return status("noDataStore", "No data store"); + if (summary.failed > 0) return status("failed", `${summary.failed} failed`); + if (summary.running > 0) + return status("running", `${summary.running} running`); + if (summary.waiting > 0) + return status("waiting", `${summary.waiting} waiting`); + if (summary.stopped > 0) + return status("stopped", `${summary.stopped} stopped`); + if (summary.executed === summary.total) return status("complete", "Complete"); + return status("idle", "Idle"); +} + +export const PROJECT_STATUS_FILTER_OPTIONS: Array<{ + label: string; + value: ProjectStatusKey; +}> = [ + { label: "No data store", value: "noDataStore" }, + { label: "Failed", value: "failed" }, + { label: "Waiting on Hub", value: "waiting" }, + { label: "Running", value: "running" }, + { label: "Stopped", value: "stopped" }, + { label: "Idle", value: "idle" }, + { label: "Complete", value: "complete" }, + { label: "No analyses", value: "noAnalyses" }, +]; diff --git a/test/components/projects/ProjectProposalTable.spec.ts b/test/components/projects/ProjectProposalTable.spec.ts deleted file mode 100644 index 3799fe5..0000000 --- a/test/components/projects/ProjectProposalTable.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useToast } from "primevue/usetoast"; -import { defineComponent } from "vue"; -import { flushPromises, mount } from "@vue/test-utils"; -import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; -import ProjectProposalTable from "~/components/projects/ProjectProposalTable.vue"; -import { getProjectNodes } from "~/composables/useAPIFetch"; -import type { ProjectNode } from "~/services/Api"; -import { fakeProposalsResp } from "@/test/components/projects/constants"; - -vi.mock("~/composables/useAPIFetch", () => ({ - getProjectNodes: vi.fn(), -})); - -describe("ProjectProposalTable.vue", () => { - let mockToast; - let ProjectProposalTableTestComponent; - - beforeEach(() => { - vi.restoreAllMocks(); // Reset mocks before each test - vi.mocked(useToast).mockReturnValue(mockToast); - }); - - // Render the component with the fake params - beforeAll(async () => { - ProjectProposalTableTestComponent = defineComponent({ - components: { ProjectProposalTable }, - template: "", - }); - }); - - test("Return project data", async () => { - vi.mocked(getProjectNodes).mockResolvedValue({ - data: ref(fakeProposalsResp), - pending: ref(false), - error: ref(undefined), - status: ref("success"), - refresh: vi.fn(), - execute: vi.fn(), - clear: vi.fn(), - }); - - const wrapper = mount(ProjectProposalTableTestComponent); - await flushPromises(); - - expect(ProjectProposalTableTestComponent).toBeTruthy(); - expect(wrapper.text()).toContain("Project Proposals"); // H1 of the page - - // Find header and all rows - const rows = wrapper.findAll("tbody tr"); - expect(rows.length).toBe(1); // Ensure 1 row exists as defined in fakeProposalsResp - - // Verify header contents - const headerRow = wrapper.findAll("thead tr"); - expect(headerRow.length).toBe(1); - const headerCols = headerRow[0].findAll("th"); - expect(headerCols.length).toBe(6); - expect(headerCols[0].text()).toBe("Project Name"); - expect(headerCols[1].text()).toBe("Number of Analyses"); - expect(headerCols[2].text()).toBe("Number of Nodes"); - expect(headerCols[3].text()).toBe("Created On"); - expect(headerCols[4].text()).toBe("Last Updated"); - expect(headerCols[5].text()).toBe("Set Approval"); - - // Verify the row's content - const rowCells = rows[0].findAll("td"); - expect(rowCells[0].text()).toBe("fake-project"); // Project name - expect(rowCells[1].text()).toBe("17"); // Number of analyses - expect(rowCells[2].text()).toBe("0"); // Number of nodes - - // The "Set Approval" column renders the toggle with both status tags - expect(rowCells[5].text()).toContain("approved"); - expect(rowCells[5].text()).toContain("rejected"); - }); - - test("No projects returned", async () => { - const emptyResp: ProjectNode[] = []; - vi.mocked(getProjectNodes).mockResolvedValue({ - data: ref(emptyResp), - pending: ref(false), - error: ref(undefined), - status: ref("success"), - refresh: vi.fn(), - execute: vi.fn(), - clear: vi.fn(), - }); - - const wrapper = mount(ProjectProposalTableTestComponent); - await flushPromises(); - - expect(ProjectProposalTableTestComponent).toBeTruthy(); - expect(wrapper.text()).toContain("No projects found"); // H1 of the page - }); - - test("API error", async () => { - vi.mocked(getProjectNodes).mockResolvedValue({ - data: ref(undefined), - pending: ref(false), - error: ref(undefined), - status: ref("error"), - refresh: vi.fn(), - execute: vi.fn(), - clear: vi.fn(), - }); - - const wrapper = mount(ProjectProposalTableTestComponent); - await flushPromises(); - - expect(ProjectProposalTableTestComponent).toBeTruthy(); - expect(wrapper.text()).toContain("No projects found"); // H1 of the page - }); -}); diff --git a/test/components/projects/ProjectsTable.spec.ts b/test/components/projects/ProjectsTable.spec.ts new file mode 100644 index 0000000..e108e90 --- /dev/null +++ b/test/components/projects/ProjectsTable.spec.ts @@ -0,0 +1,363 @@ +import { useToast } from "primevue/usetoast"; +import { computed, defineComponent } from "vue"; +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { FilterMatchMode } from "@primevue/core/api"; +import ProjectsTable from "~/components/projects/ProjectsTable.vue"; +import { getProjectNodes } from "~/composables/useAPIFetch"; +import { useDatastoreRequirement } from "~/composables/useDatastoreRequirement"; +import { useProjectAnalysisSummary } from "~/composables/useProjectAnalysisSummary"; +import { emptyProjectAnalysisSummary } from "~/utils/summarise-project-analyses"; +import type { ProjectAnalysisSummary } from "~/utils/summarise-project-analyses"; +import type { ProjectNode } from "~/services/Api"; +import { + FAKE_PROJECT_ID, + SECOND_FAKE_PROJECT_ID, + fakeProposalsResp, + fakeTwoProposalsResp, +} from "@/test/components/projects/constants"; + +vi.mock("~/composables/useAPIFetch", () => ({ + getProjectNodes: vi.fn(), +})); + +vi.mock("~/composables/useDatastoreRequirement", () => ({ + useDatastoreRequirement: vi.fn(), +})); + +vi.mock("~/composables/useProjectAnalysisSummary", () => ({ + useProjectAnalysisSummary: vi.fn(), +})); + +function mockProjectNodes( + data: ProjectNode[] | undefined, + status: "success" | "error" = "success", +) { + vi.mocked(getProjectNodes).mockResolvedValue({ + data: ref(data), + pending: ref(false), + error: ref(undefined), + status: ref(status), + refresh: vi.fn(), + execute: vi.fn(), + clear: vi.fn(), + }); +} + +function mockSummaries( + entries: Record>, + composableOverrides: { truncated?: boolean; loading?: boolean } = {}, +) { + const summaries = new Map( + Object.entries(entries).map(([projectId, overrides]) => [ + projectId, + { ...emptyProjectAnalysisSummary(true), ...overrides }, + ]), + ); + + vi.mocked(useProjectAnalysisSummary).mockReturnValue({ + summaries: ref(summaries), + dataStoreProjectIds: ref(new Set(summaries.keys())), + loading: ref(composableOverrides.loading ?? false), + truncated: ref(composableOverrides.truncated ?? false), + refreshSummaries: vi.fn(), + // Id-aware on purpose: a mock that ignored its argument would pass even if + // the component looked the summary up by the wrong id (row.id, node_id...). + summaryFor: (id: string | undefined | null) => + (id ? summaries.get(id) : undefined) ?? + emptyProjectAnalysisSummary(false), + } as never); +} + +function mockSummary( + overrides: Partial = {}, + composableOverrides: { truncated?: boolean; loading?: boolean } = {}, +) { + mockSummaries({ [FAKE_PROJECT_ID]: overrides }, composableOverrides); +} + +describe("ProjectsTable.vue", () => { + let mockToast; + let ProjectsTableTestComponent; + + beforeEach(() => { + vi.restoreAllMocks(); // Reset mocks before each test + vi.mocked(useToast).mockReturnValue(mockToast); + + vi.mocked(useDatastoreRequirement).mockReturnValue({ + nodeType: computed(() => "default"), + requireDataStore: computed(() => true), + } as never); + mockSummary({ total: 6, executed: 4, running: 2 }); + }); + + // Render the component with the fake params + beforeAll(async () => { + ProjectsTableTestComponent = defineComponent({ + components: { ProjectsTable }, + template: "", + }); + }); + + test("Return project data", async () => { + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + expect(ProjectsTableTestComponent).toBeTruthy(); + expect(wrapper.text()).toContain("Projects"); // Card title + + // Find header and all rows + const rows = wrapper.findAll("tbody tr"); + expect(rows.length).toBe(1); // Ensure 1 row exists as defined in fakeProposalsResp + + // Verify header contents + const headerRow = wrapper.findAll("thead tr"); + expect(headerRow.length).toBe(1); + const headerCols = headerRow[0].findAll("th"); + expect(headerCols.length).toBe(7); + expect(headerCols[0].text()).toBe("Project Name"); + expect(headerCols[1].text()).toBe("Analyses"); + expect(headerCols[2].text()).toBe("Number of Nodes"); + expect(headerCols[3].text()).toBe("Status"); + expect(headerCols[4].text()).toBe("Data Store"); + expect(headerCols[5].text()).toBe("Created On"); + expect(headerCols[6].text()).toBe("Last Updated"); + + // Verify the row's content + const rowCells = rows[0].findAll("td"); + expect(rowCells[0].text()).toBe("fake-project"); // Project name + expect(rowCells[1].text()).toBe("6"); // Node-local analyses, not project.analyses (17) + expect(rowCells[2].text()).toBe("0"); // Number of nodes + }); + + test("Marks a project that has a data store", async () => { + mockSummary({ total: 2, executed: 2, hasDataStore: true }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const dataStoreCell = wrapper.findAll("tbody tr")[0].findAll("td")[4]; + expect(dataStoreCell.find(".pi-check").exists()).toBe(true); + }); + + test("Links a project with no data store to data store creation", async () => { + mockSummary({ total: 2, waiting: 2, hasDataStore: false }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const dataStoreCell = wrapper.findAll("tbody tr")[0].findAll("td")[4]; + expect(dataStoreCell.find(".pi-times").exists()).toBe(true); + expect(dataStoreCell.find("button").attributes("aria-label")).toContain( + "create a data store", + ); + }); + + test("Hides the data store column on an aggregator node", async () => { + vi.mocked(useDatastoreRequirement).mockReturnValue({ + nodeType: computed(() => "aggregator"), + requireDataStore: computed(() => false), + } as never); + mockSummary({ total: 2, executed: 2, hasDataStore: false }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const headerCols = wrapper.findAll("thead tr")[0].findAll("th"); + expect(headerCols.length).toBe(6); + expect(headerCols.map((col) => col.text())).not.toContain("Data Store"); + }); + + test("Re-derives the status when the data store requirement resolves late", async () => { + // The node settings plugin does not await fetchSettings(), so the getter + // sits at its default `true` until the request lands. + const requireDataStore = ref(true); + vi.mocked(useDatastoreRequirement).mockReturnValue({ + nodeType: computed(() => "default"), + requireDataStore, + } as never); + mockSummary({ total: 3, failed: 2, idle: 1, hasDataStore: false }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const statusCell = () => + wrapper.findAll("tbody tr")[0].findAll("td")[3].text(); + expect(statusCell()).toContain("No data store"); + + requireDataStore.value = false; + await flushPromises(); + + // The verdict must follow the setting, otherwise it contradicts the + // "not required" data store badge rendered from the same setting. + expect(statusCell()).toContain("2 failed"); + }); + + test("Renders the status tag and one meter segment per non-empty bucket", async () => { + mockSummary({ total: 6, executed: 3, failed: 2, idle: 1 }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const statusCell = wrapper.findAll("tbody tr")[0].findAll("td")[3]; + expect(statusCell.text()).toContain("2 failed"); + + const segments = statusCell.findAll(".status-meter-seg"); + expect(segments.length).toBe(3); // executed, failed, idle — not the empty buckets + expect(segments[0].classes()).toContain("status-meter-executed"); + expect(segments[1].classes()).toContain("status-meter-failed"); + expect(segments[2].classes()).toContain("status-meter-idle"); + }); + + test("Exposes the meter counts to assistive technology", async () => { + mockSummary({ total: 6, executed: 3, failed: 2, idle: 1 }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const meter = wrapper.findAll("tbody tr")[0].find(".status-meter"); + expect(meter.attributes("role")).toBe("img"); + expect(meter.attributes("aria-label")).toBe("3 executed, 2 failed, 1 idle"); + }); + + test("Renders no meter when the project has no analyses on this node", async () => { + mockSummary({ total: 0 }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const statusCell = wrapper.findAll("tbody tr")[0].findAll("td")[3]; + expect(statusCell.text()).toContain("No analyses"); + expect(statusCell.findAll(".status-meter-seg").length).toBe(0); + }); + + test("Renders the legend once, above the table", async () => { + mockSummary({ total: 3, executed: 3 }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + expect(wrapper.findAll(".status-legend").length).toBe(1); + }); + + test("Warns that the counts are partial when pagination was truncated", async () => { + mockSummary({ total: 3, executed: 3 }, { truncated: true }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const warning = wrapper.find(".status-truncation-warning"); + expect(warning.exists()).toBe(true); + expect(warning.text()).toContain("partial"); + }); + + test("Shows no truncation warning when everything was loaded", async () => { + mockSummary({ total: 3, executed: 3 }, { truncated: false }); + mockProjectNodes(fakeProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + expect(wrapper.find(".status-truncation-warning").exists()).toBe(false); + }); + + test("Sorts rows worst-first by status rank", async () => { + // Healthy project first in the API response, broken one second — so raw + // (unsorted) order and rank order disagree. + mockSummaries({ + [FAKE_PROJECT_ID]: { total: 4, executed: 4 }, // Complete, rank 7 + [SECOND_FAKE_PROJECT_ID]: { total: 3, failed: 3 }, // 3 failed, rank 2 + }); + mockProjectNodes(fakeTwoProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + const names = () => + wrapper.findAll("tbody tr").map((row) => row.findAll("td")[0].text()); + + expect(names()).toEqual(["second-project", "fake-project"]); + expect(wrapper.findAll("tbody tr")[0].findAll("td")[3].text()).toContain( + "3 failed", + ); + + // The column must also be sortable, otherwise the default ordering is + // fixed and the administrator cannot re-sort it. + const statusHeader = wrapper.findAll("thead tr")[0].findAll("th")[3]; + expect(statusHeader.attributes("data-p-sortable-column")).toBe("true"); + + await statusHeader.trigger("click"); + await flushPromises(); + + expect(names()).toEqual(["fake-project", "second-project"]); + }); + + test("Filters rows by the selected status", async () => { + mockSummaries({ + [FAKE_PROJECT_ID]: { total: 4, executed: 4 }, // complete + [SECOND_FAKE_PROJECT_ID]: { total: 3, failed: 3 }, // failed + }); + mockProjectNodes(fakeTwoProposalsResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + expect(wrapper.findAll("tbody tr").length).toBe(2); + + // Drive the real filter menu rather than poking the filters object, so the + // column's filterField -> filterModel -> FilterMatchMode.IN wiring is + // exercised end to end. + const statusHeader = wrapper.findAll("thead tr")[0].findAll("th")[3]; + await statusHeader + .find(".p-datatable-column-filter-button") + .trigger("click"); + await flushPromises(); + + const statusFilter = wrapper.findComponent({ name: "MultiSelect" }); + expect(statusFilter.exists()).toBe(true); + expect( + statusFilter.props("options").map((option) => option.value), + ).toContain("failed"); + + statusFilter.vm.$emit("update:modelValue", ["failed"]); + statusFilter.vm.$emit("change", { value: ["failed"] }); + await flushPromises(); + + const rows = wrapper.findAll("tbody tr"); + expect(rows.length).toBe(1); + expect(rows[0].findAll("td")[0].text()).toBe("second-project"); + expect(rows[0].findAll("td")[3].text()).toContain("3 failed"); + }); + + test("No projects returned", async () => { + const emptyResp: ProjectNode[] = []; + mockProjectNodes(emptyResp); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + expect(ProjectsTableTestComponent).toBeTruthy(); + expect(wrapper.text()).toContain("No projects found"); // H1 of the page + }); + + test("API error", async () => { + mockProjectNodes(undefined, "error"); + + const wrapper = mount(ProjectsTableTestComponent); + await flushPromises(); + + expect(ProjectsTableTestComponent).toBeTruthy(); + expect(wrapper.text()).toContain("No projects found"); // H1 of the page + }); +}); diff --git a/test/components/projects/constants.ts b/test/components/projects/constants.ts index 516a5d1..f510314 100644 --- a/test/components/projects/constants.ts +++ b/test/components/projects/constants.ts @@ -1,5 +1,8 @@ import type { ProjectNode } from "~/services/Api"; +export const FAKE_PROJECT_ID = "7f2f3b59-3b6d-4fb6-a900-2a4d5c2ea483"; +export const SECOND_FAKE_PROJECT_ID = "0d4c1e57-8b2a-4d16-9e33-5f7a1c8b2e90"; + export const fakeProposalsResp: ProjectNode[] = [ { id: "73497486-a46d-49b4-b6ec-9f463e18d7dd", @@ -43,3 +46,21 @@ export const fakeProposalsResp: ProjectNode[] = [ }, }, ]; + +// Two projects, deliberately supplied in the API's `-updated_at` order so that +// the healthy one comes FIRST in the raw response. Any test asserting +// worst-first order therefore fails if the table stops sorting by status rank. +export const fakeTwoProposalsResp: ProjectNode[] = [ + fakeProposalsResp[0]!, + { + ...fakeProposalsResp[0]!, + id: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + project_id: SECOND_FAKE_PROJECT_ID, + project: { + ...fakeProposalsResp[0]!.project!, + id: SECOND_FAKE_PROJECT_ID, + name: "second-project", + display_name: "second-project", + }, + }, +]; diff --git a/test/components/shared/DataStoreBadge.spec.ts b/test/components/shared/DataStoreBadge.spec.ts new file mode 100644 index 0000000..6060f56 --- /dev/null +++ b/test/components/shared/DataStoreBadge.spec.ts @@ -0,0 +1,66 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, test } from "vitest"; +import DataStoreBadge from "~/components/shared/DataStoreBadge.vue"; + +describe("DataStoreBadge.vue", () => { + test("shows a check when the data store is present", () => { + const wrapper = mount(DataStoreBadge, { + props: { hasDataStore: true, required: true, projectId: "proj-1" }, + }); + + expect(wrapper.find(".pi-check").exists()).toBe(true); + expect(wrapper.find("button").exists()).toBe(false); + }); + + test("shows an actionable cross when the data store is missing and required", () => { + const wrapper = mount(DataStoreBadge, { + props: { hasDataStore: false, required: true, projectId: "proj-1" }, + }); + + const button = wrapper.find("button"); + expect(wrapper.find(".pi-times").exists()).toBe(true); + expect(button.attributes("disabled")).toBeUndefined(); + expect(button.attributes("aria-label")).toContain("create a data store"); + }); + + test("renders a non-interactive cross when a data store is not required", () => { + const wrapper = mount(DataStoreBadge, { + props: { hasDataStore: false, required: false, projectId: "proj-1" }, + }); + + expect(wrapper.find("button").exists()).toBe(false); + const span = wrapper.find("span.datastore-icon-btn"); + expect(span.exists()).toBe(true); + expect(span.attributes("aria-label")).toContain("not required"); + }); + + test("emits createDataStore with the project id when clicked", async () => { + const wrapper = mount(DataStoreBadge, { + props: { hasDataStore: false, required: true, projectId: "proj-1" }, + }); + + await wrapper.find("button").trigger("click"); + + expect(wrapper.emitted("createDataStore")).toEqual([["proj-1"]]); + }); + + test("emits createDataStore with a null project id when there is none", async () => { + const wrapper = mount(DataStoreBadge, { + props: { hasDataStore: false, required: true, projectId: null }, + }); + + await wrapper.find("button").trigger("click"); + + expect(wrapper.emitted("createDataStore")).toEqual([[null]]); + }); + + test("does not emit when a data store is not required", async () => { + const wrapper = mount(DataStoreBadge, { + props: { hasDataStore: false, required: false, projectId: "proj-1" }, + }); + + await wrapper.find("span.datastore-icon-btn").trigger("click"); + + expect(wrapper.emitted("createDataStore")).toBeUndefined(); + }); +}); diff --git a/test/components/table/ApproveRejectToggle.spec.ts b/test/components/table/ApproveRejectToggle.spec.ts deleted file mode 100644 index 64642be..0000000 --- a/test/components/table/ApproveRejectToggle.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { useToast } from "primevue/usetoast"; -import { useConfirm } from "primevue/useconfirm"; -import { flushPromises, mount } from "@vue/test-utils"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import ApproveRejectToggle from "~/components/table/ApproveRejectToggle.vue"; -import { ApprovalStatus } from "~/types/node"; -import { - fakeInvalidProposalId, - fakeValidProposalId, -} from "@/test/mockapi/handlers"; - -// Stubbed ToggleSwitch so we can drive the `@click` handler deterministically. -// It exposes the bound model value via `data-checked` and intentionally does -// NOT emit `update:modelValue` on click, so `checked` only changes via the -// component's own reset logic. -const ToggleSwitchStub = { - props: ["modelValue"], - emits: ["update:modelValue"], - template: '