diff --git a/src/lib/dtoParsers.ts b/src/lib/dtoParsers.ts new file mode 100644 index 0000000..8f62424 --- /dev/null +++ b/src/lib/dtoParsers.ts @@ -0,0 +1,58 @@ +import type { ChamberDto, FormationProposalPageDto } from "@/types/api"; + +export function parseCommaNumber(value: string): number { + const cleaned = value.replace(/,/g, "").trim(); + const parsed = Number.parseInt(cleaned, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function parsePercent(value: string): number { + const cleaned = value.replace(/%/g, "").trim(); + const parsed = Number.parseInt(cleaned, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function parseRatio(value: string): { a: number; b: number } { + const parts = value + .split("/") + .map((part) => Number.parseInt(part.trim(), 10)); + if (parts.length !== 2) return { a: 0, b: 0 }; + const [a, b] = parts; + return { + a: Number.isFinite(a) ? a : 0, + b: Number.isFinite(b) ? b : 0, + }; +} + +export function getChamberNumericStats(chamber: ChamberDto) { + return { + governors: parseCommaNumber(chamber.stats.governors), + acm: parseCommaNumber(chamber.stats.acm), + lcm: parseCommaNumber(chamber.stats.lcm), + mcm: parseCommaNumber(chamber.stats.mcm), + }; +} + +export function computeChamberMetrics(chambers: ChamberDto[]) { + const totalAcm = chambers.reduce((sum, chamber) => { + const { acm } = getChamberNumericStats(chamber); + return sum + acm; + }, 0); + const liveProposals = chambers.reduce( + (sum, chamber) => sum + (chamber.pipeline.vote ?? 0), + 0, + ); + return { + totalChambers: chambers.length, + totalAcm, + liveProposals, + }; +} + +export function getFormationProgress(formationPage: FormationProposalPageDto) { + return { + progressValue: parsePercent(formationPage.progress), + team: parseRatio(formationPage.teamSlots), + milestones: parseRatio(formationPage.milestones), + }; +} diff --git a/src/lib/proposalSubmitErrors.ts b/src/lib/proposalSubmitErrors.ts new file mode 100644 index 0000000..54089e9 --- /dev/null +++ b/src/lib/proposalSubmitErrors.ts @@ -0,0 +1,38 @@ +import { getApiErrorPayload } from "@/lib/apiClient"; +import { formatProposalType } from "@/lib/proposalTypes"; + +export function formatProposalSubmitError(error: unknown): string { + const payload = getApiErrorPayload(error); + const details = payload?.error ?? null; + if (!details) return (error as Error).message ?? "Submit failed."; + + const code = typeof details.code === "string" ? details.code : ""; + if (code === "proposal_type_ineligible" || code === "tier_ineligible") { + const requiredTier = + typeof details.requiredTier === "string" + ? details.requiredTier + : "a higher tier"; + const proposalType = + typeof details.proposalType === "string" + ? formatProposalType(details.proposalType) + : "this"; + return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`; + } + + if (code === "proposal_submit_ineligible") { + const chamberId = + typeof details.chamberId === "string" ? details.chamberId : ""; + if (chamberId === "general") { + return "General chamber proposals require voting rights in any chamber."; + } + if (chamberId) { + return `Only chamber members can submit to ${formatProposalType(chamberId)}.`; + } + } + + if (code === "draft_not_submittable") { + return "Draft is incomplete. Fill required fields before submitting."; + } + + return details.message ?? (error as Error).message ?? "Submit failed."; +} diff --git a/src/lib/proposalTypes.ts b/src/lib/proposalTypes.ts new file mode 100644 index 0000000..abe2f22 --- /dev/null +++ b/src/lib/proposalTypes.ts @@ -0,0 +1,12 @@ +export const proposalTypeLabel: Record = { + basic: "Basic", + fee: "Fee distribution", + monetary: "Monetary system", + core: "Core infrastructure", + administrative: "Administrative", + "dao-core": "DAO core", +}; + +export function formatProposalType(value: string): string { + return proposalTypeLabel[value] ?? value.replace(/-/g, " "); +} diff --git a/src/pages/chambers/Chambers.tsx b/src/pages/chambers/Chambers.tsx index 0922eae..c33314f 100644 --- a/src/pages/chambers/Chambers.tsx +++ b/src/pages/chambers/Chambers.tsx @@ -13,6 +13,10 @@ import { Link } from "react-router"; import { InlineHelp } from "@/components/InlineHelp"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { apiChambers } from "@/lib/apiClient"; +import { + computeChamberMetrics, + getChamberNumericStats, +} from "@/lib/dtoParsers"; import type { ChamberDto } from "@/types/api"; import { Surface } from "@/components/Surface"; @@ -77,32 +81,21 @@ const Chambers: React.FC = () => { }) .sort((a, b) => { if (sortBy === "name") return a.name.localeCompare(b.name); - if (sortBy === "governors") - return ( - parseInt(b.stats.governors, 10) - parseInt(a.stats.governors, 10) - ); - return ( - parseInt(b.stats.acm.replace(/[,]/g, ""), 10) - - parseInt(a.stats.acm.replace(/[,]/g, ""), 10) - ); + const statsA = getChamberNumericStats(a); + const statsB = getChamberNumericStats(b); + if (sortBy === "governors") return statsB.governors - statsA.governors; + return statsB.acm - statsA.acm; }); }, [chambers, search, pipelineFilter, sortBy]); const computedMetrics = useMemo((): Metric[] => { if (!chambers) return metricCards; - const totalAcm = chambers.reduce((sum, chamber) => { - const parsed = Number(chamber.stats.acm.replace(/,/g, "")); - return sum + (Number.isFinite(parsed) ? parsed : 0); - }, 0); - const live = chambers.reduce( - (sum, chamber) => sum + (chamber.pipeline.vote ?? 0), - 0, - ); + const { totalAcm, liveProposals } = computeChamberMetrics(chambers); return [ { label: "Total chambers", value: String(chambers.length) }, { label: "Active governors", value: "150" }, { label: "Total ACM", value: totalAcm.toLocaleString() }, - { label: "Live proposals", value: String(live) }, + { label: "Live proposals", value: String(liveProposals) }, ]; }, [chambers]); diff --git a/src/pages/proposals/ProposalCreation.tsx b/src/pages/proposals/ProposalCreation.tsx index 4ffd5b9..ef61c53 100644 --- a/src/pages/proposals/ProposalCreation.tsx +++ b/src/pages/proposals/ProposalCreation.tsx @@ -11,12 +11,12 @@ import { Tabs } from "@/components/primitives/tabs"; import { PageHint } from "@/components/PageHint"; import { SIM_AUTH_ENABLED } from "@/lib/featureFlags"; import { useAuth } from "@/app/auth/AuthContext"; +import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors"; import { apiChambers, apiProposalDraftDelete, apiProposalDraftSave, apiProposalSubmitToPool, - getApiErrorPayload, } from "@/lib/apiClient"; import type { ChamberDto } from "@/types/api"; import { BudgetStep } from "./proposalCreation/steps/BudgetStep"; @@ -43,54 +43,6 @@ import { } from "./proposalCreation/types"; import { getWizardTemplate } from "./proposalCreation/templates/registry"; -const proposalTypeLabel: Record = { - basic: "Basic", - fee: "Fee distribution", - monetary: "Monetary system", - core: "Core infrastructure", - administrative: "Administrative", - "dao-core": "DAO core", -}; - -const formatProposalType = (value: string): string => - proposalTypeLabel[value] ?? value.replace(/-/g, " "); - -const formatSubmitError = (error: unknown): string => { - const payload = getApiErrorPayload(error); - const details = payload?.error ?? null; - if (!details) return (error as Error).message ?? "Submit failed."; - - const code = typeof details.code === "string" ? details.code : ""; - if (code === "proposal_type_ineligible" || code === "tier_ineligible") { - const requiredTier = - typeof details.requiredTier === "string" - ? details.requiredTier - : "a higher tier"; - const proposalType = - typeof details.proposalType === "string" - ? formatProposalType(details.proposalType) - : "this"; - return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`; - } - - if (code === "proposal_submit_ineligible") { - const chamberId = - typeof details.chamberId === "string" ? details.chamberId : ""; - if (chamberId === "general") { - return "General chamber proposals require voting rights in any chamber."; - } - if (chamberId) { - return `Only chamber members can submit to ${formatProposalType(chamberId)}.`; - } - } - - if (code === "draft_not_submittable") { - return "Draft is incomplete. Fill required fields before submitting."; - } - - return details.message ?? (error as Error).message ?? "Submit failed."; -}; - const ProposalCreation: React.FC = () => { const auth = useAuth(); const navigate = useNavigate(); @@ -435,7 +387,7 @@ const ProposalCreation: React.FC = () => { clearDraftStorage(); navigate(`/app/proposals/${res.proposalId}/pp`); } catch (error) { - setSubmitError(formatSubmitError(error)); + setSubmitError(formatProposalSubmitError(error)); } finally { setSubmitting(false); } diff --git a/src/pages/proposals/ProposalDraft.tsx b/src/pages/proposals/ProposalDraft.tsx index 65647b2..132f7d5 100644 --- a/src/pages/proposals/ProposalDraft.tsx +++ b/src/pages/proposals/ProposalDraft.tsx @@ -17,61 +17,10 @@ import { AttachmentList } from "@/components/AttachmentList"; import { TitledSurface } from "@/components/TitledSurface"; import { SIM_AUTH_ENABLED } from "@/lib/featureFlags"; import { useAuth } from "@/app/auth/AuthContext"; -import { - apiProposalDraft, - apiProposalSubmitToPool, - getApiErrorPayload, -} from "@/lib/apiClient"; +import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors"; +import { apiProposalDraft, apiProposalSubmitToPool } from "@/lib/apiClient"; import type { ProposalDraftDetailDto } from "@/types/api"; -const proposalTypeLabel: Record = { - basic: "Basic", - fee: "Fee distribution", - monetary: "Monetary system", - core: "Core infrastructure", - administrative: "Administrative", - "dao-core": "DAO core", -}; - -const formatProposalType = (value: string): string => - proposalTypeLabel[value] ?? value.replace(/-/g, " "); - -const formatSubmitError = (error: unknown): string => { - const payload = getApiErrorPayload(error); - const details = payload?.error ?? null; - if (!details) return (error as Error).message ?? "Submit failed."; - - const code = typeof details.code === "string" ? details.code : ""; - if (code === "proposal_type_ineligible" || code === "tier_ineligible") { - const requiredTier = - typeof details.requiredTier === "string" - ? details.requiredTier - : "a higher tier"; - const proposalType = - typeof details.proposalType === "string" - ? formatProposalType(details.proposalType) - : "this"; - return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`; - } - - if (code === "proposal_submit_ineligible") { - const chamberId = - typeof details.chamberId === "string" ? details.chamberId : ""; - if (chamberId === "general") { - return "General chamber proposals require voting rights in any chamber."; - } - if (chamberId) { - return `Only chamber members can submit to ${formatProposalType(chamberId)}.`; - } - } - - if (code === "draft_not_submittable") { - return "Draft is incomplete. Fill required fields before submitting."; - } - - return details.message ?? (error as Error).message ?? "Submit failed."; -}; - const ProposalDraft: React.FC = () => { const auth = useAuth(); const { id } = useParams(); @@ -159,7 +108,7 @@ const ProposalDraft: React.FC = () => { const res = await apiProposalSubmitToPool({ draftId: id }); window.location.href = `/app/proposals/${res.proposalId}/pp`; } catch (error) { - setSubmitError(formatSubmitError(error)); + setSubmitError(formatProposalSubmitError(error)); } finally { setSubmitting(false); } diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx index 0f1fe9d..e24e2c0 100644 --- a/src/pages/proposals/Proposals.tsx +++ b/src/pages/proposals/Proposals.tsx @@ -14,6 +14,7 @@ import type { ProposalStage } from "@/types/stages"; import { CardActionsRow } from "@/components/CardActionsRow"; import { Surface } from "@/components/Surface"; import { NoDataYetBar } from "@/components/NoDataYetBar"; +import { getFormationProgress } from "@/lib/dtoParsers"; import { apiProposalChamberPage, apiProposalFormationPage, @@ -373,33 +374,7 @@ const Proposals: React.FC = () => { const formationStats = proposal.stage === "build" && formationPage - ? (() => { - const progressRaw = Number.parseInt( - formationPage.progress.replace("%", ""), - 10, - ); - const progressValue = Number.isFinite(progressRaw) - ? progressRaw - : 0; - - const parsePair = (value: string) => { - const parts = value - .split("/") - .map((v) => Number(v.trim())); - if (parts.length !== 2) return { a: 0, b: 0 }; - const [a, b] = parts; - return { - a: Number.isFinite(a) ? a : 0, - b: Number.isFinite(b) ? b : 0, - }; - }; - - return { - progressValue, - team: parsePair(formationPage.teamSlots), - milestones: parsePair(formationPage.milestones), - }; - })() + ? getFormationProgress(formationPage) : null; return ( diff --git a/tests/api/api-client-runtime.test.js b/tests/api/api-client-runtime.test.js new file mode 100644 index 0000000..dd35cb9 --- /dev/null +++ b/tests/api/api-client-runtime.test.js @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { test } from "@rstest/core"; + +import { + apiChambers, + apiPoolVote, + apiProposals, +} from "../../src/lib/apiClient.ts"; + +test("api client respects runtime base URL, headers, and credentials", async () => { + const originalFetch = global.fetch; + const originalWindow = global.window; + const calls = []; + + global.window = { + __VORTEX_CONFIG__: { + apiBaseUrl: "https://api.example.test", + apiHeaders: { "x-test-header": "ok" }, + apiCredentials: "omit", + }, + }; + + global.fetch = async (input, init) => { + calls.push({ input, init }); + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const res = await apiChambers(); + assert.ok(Array.isArray(res.items)); + assert.equal(calls.length, 1); + assert.equal(calls[0].input, "https://api.example.test/api/chambers"); + assert.equal(calls[0].init.credentials, "omit"); + assert.equal(calls[0].init.headers["x-test-header"], "ok"); + + global.fetch = originalFetch; + global.window = originalWindow; +}); + +test("apiPost sends JSON body and idempotency header when provided", async () => { + const originalFetch = global.fetch; + const originalWindow = global.window; + const calls = []; + + global.window = { + __VORTEX_CONFIG__: { + apiBaseUrl: "https://api.example.test", + apiHeaders: { "x-global": "yes" }, + }, + }; + + global.fetch = async (input, init) => { + calls.push({ input, init }); + return new Response( + JSON.stringify({ + ok: true, + type: "pool.vote", + proposalId: "proposal-1", + direction: "up", + counts: { upvotes: 1, downvotes: 0 }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }; + + await apiPoolVote({ + proposalId: "proposal-1", + direction: "up", + idempotencyKey: "idem-123", + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].input, "https://api.example.test/api/command"); + assert.equal(calls[0].init.method, "POST"); + assert.equal(calls[0].init.headers["idempotency-key"], "idem-123"); + assert.equal(calls[0].init.headers["content-type"], "application/json"); + assert.equal(calls[0].init.headers["x-global"], "yes"); + + global.fetch = originalFetch; + global.window = originalWindow; +}); + +test("apiGet throws a structured error for non-2xx responses", async () => { + const originalFetch = global.fetch; + const originalWindow = global.window; + + global.window = { __VORTEX_CONFIG__: { apiBaseUrl: "" } }; + global.fetch = async () => + new Response( + JSON.stringify({ error: { message: "Nope", code: "denied" } }), + { + status: 403, + headers: { "content-type": "application/json" }, + }, + ); + + let thrown = null; + try { + await apiProposals(); + } catch (error) { + thrown = error; + } + + assert.ok(thrown instanceof Error); + assert.equal(thrown.status, 403); + assert.equal(thrown.data?.error?.code, "denied"); + assert.ok(thrown.message.includes("HTTP 403")); + + global.fetch = originalFetch; + global.window = originalWindow; +}); diff --git a/tests/unit/dto-parsers.test.ts b/tests/unit/dto-parsers.test.ts new file mode 100644 index 0000000..fff044d --- /dev/null +++ b/tests/unit/dto-parsers.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "@rstest/core"; + +import { + computeChamberMetrics, + getChamberNumericStats, + getFormationProgress, + parseCommaNumber, + parsePercent, + parseRatio, +} from "../../src/lib/dtoParsers.ts"; +import type { ChamberDto, FormationProposalPageDto } from "../../src/types/api"; + +test("parseCommaNumber handles commas and invalids", () => { + expect(parseCommaNumber("1,200")).toBe(1200); + expect(parseCommaNumber("0")).toBe(0); + expect(parseCommaNumber("bad")).toBe(0); +}); + +test("parsePercent and parseRatio normalize values", () => { + expect(parsePercent("45%")).toBe(45); + expect(parsePercent("bad")).toBe(0); + expect(parseRatio("3/8")).toEqual({ a: 3, b: 8 }); + expect(parseRatio("bad")).toEqual({ a: 0, b: 0 }); +}); + +test("chamber numeric stats and metrics aggregate", () => { + const chambers: ChamberDto[] = [ + { + id: "general", + name: "General", + multiplier: 1, + stats: { + governors: "10", + acm: "1,200", + lcm: "200", + mcm: "400", + }, + pipeline: { pool: 0, vote: 2, build: 1 }, + }, + { + id: "design", + name: "Design", + multiplier: 1.2, + stats: { + governors: "5", + acm: "800", + lcm: "100", + mcm: "300", + }, + pipeline: { pool: 1, vote: 0, build: 0 }, + }, + ]; + + expect(getChamberNumericStats(chambers[0])).toEqual({ + governors: 10, + acm: 1200, + lcm: 200, + mcm: 400, + }); + + const metrics = computeChamberMetrics(chambers); + expect(metrics.totalChambers).toBe(2); + expect(metrics.totalAcm).toBe(2000); + expect(metrics.liveProposals).toBe(2); +}); + +test("formation progress mapping uses ratios", () => { + const formation: FormationProposalPageDto = { + title: "Formation", + chamber: "General chamber", + proposer: "Alice", + proposerId: "0xalice", + budget: "0 HMND", + timeLeft: "—", + teamSlots: "2/5", + milestones: "1/4", + progress: "40%", + stageData: [], + stats: [], + lockedTeam: [], + openSlots: [], + milestonesDetail: [], + attachments: [], + summary: "", + overview: "", + executionPlan: [], + budgetScope: "", + invisionInsight: { + role: "observer", + bullets: [], + }, + }; + + const progress = getFormationProgress(formation); + expect(progress.progressValue).toBe(40); + expect(progress.team).toEqual({ a: 2, b: 5 }); + expect(progress.milestones).toEqual({ a: 1, b: 4 }); +}); diff --git a/tests/unit/proposal-submit-errors.test.ts b/tests/unit/proposal-submit-errors.test.ts new file mode 100644 index 0000000..8f9e989 --- /dev/null +++ b/tests/unit/proposal-submit-errors.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "@rstest/core"; + +import { formatProposalSubmitError } from "../../src/lib/proposalSubmitErrors.ts"; +import { formatProposalType } from "../../src/lib/proposalTypes.ts"; + +test("formats proposal type labels", () => { + expect(formatProposalType("dao-core")).toBe("DAO core"); + expect(formatProposalType("administrative")).toBe("Administrative"); + expect(formatProposalType("custom-type")).toBe("custom type"); +}); + +test("formats tier gating errors from API payload", () => { + const error = { + data: { + error: { + code: "proposal_type_ineligible", + requiredTier: "Citizen", + proposalType: "dao-core", + }, + }, + }; + expect(formatProposalSubmitError(error)).toBe( + "Not eligible for DAO core proposals. Required tier: Citizen.", + ); +}); + +test("formats chamber submit eligibility errors", () => { + const generalError = { + data: { + error: { code: "proposal_submit_ineligible", chamberId: "general" }, + }, + }; + expect(formatProposalSubmitError(generalError)).toBe( + "General chamber proposals require voting rights in any chamber.", + ); + + const chamberError = { + data: { + error: { code: "proposal_submit_ineligible", chamberId: "engineering" }, + }, + }; + expect(formatProposalSubmitError(chamberError)).toBe( + "Only chamber members can submit to engineering.", + ); +}); + +test("falls back to error message when payload is missing", () => { + const error = new Error("Submit failed"); + expect(formatProposalSubmitError(error)).toBe("Submit failed"); +});