From 8a361e5a7717f6aed19b70b8daec15ba218a0b08 Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Tue, 5 May 2026 21:38:43 +0400 Subject: [PATCH] Complete Phase 85 web bug hunt --- src/lib/addressIdentity.ts | 60 +++++++++ src/lib/proposalVetoUi.ts | 9 ++ src/pages/chambers/Chamber.tsx | 5 +- src/pages/factions/Faction.tsx | 17 +-- src/pages/factions/FactionChannel.tsx | 11 +- src/pages/factions/FactionInitiative.tsx | 23 ++-- .../factions/FactionInitiativeCreate.tsx | 8 +- src/pages/factions/FactionThreadCreate.tsx | 9 +- src/pages/feed/Feed.tsx | 12 +- src/pages/human-nodes/HumanNode.tsx | 9 +- src/pages/proposals/ProposalChamber.tsx | 8 +- src/pages/proposals/ProposalChamberVeto.tsx | 8 +- src/pages/proposals/ProposalCitizenVeto.tsx | 17 ++- src/pages/proposals/ProposalFormation.tsx | 9 +- src/pages/proposals/ProposalPP.tsx | 8 +- src/pages/proposals/ProposalReferendum.tsx | 8 +- tests/api/cross-repo-contract.test.js | 122 ++++++++++++++++++ tests/unit/address-identity.test.ts | 21 +++ tests/unit/feed-urgent.test.ts | 27 ++++ tests/unit/proposal-veto-ui.test.ts | 28 ++++ 20 files changed, 343 insertions(+), 76 deletions(-) create mode 100644 src/lib/addressIdentity.ts create mode 100644 src/lib/proposalVetoUi.ts create mode 100644 tests/api/cross-repo-contract.test.js create mode 100644 tests/unit/address-identity.test.ts create mode 100644 tests/unit/feed-urgent.test.ts create mode 100644 tests/unit/proposal-veto-ui.test.ts diff --git a/src/lib/addressIdentity.ts b/src/lib/addressIdentity.ts new file mode 100644 index 0000000..bedb8b4 --- /dev/null +++ b/src/lib/addressIdentity.ts @@ -0,0 +1,60 @@ +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const BASE58_MAP = new Map( + [...BASE58_ALPHABET].map((char, index) => [char, index]), +); + +function bytesToHex(bytes: Uint8Array): string { + return `0x${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +function decodeBase58(value: string): Uint8Array | null { + let bytes = [0]; + for (const char of value) { + const value = BASE58_MAP.get(char); + if (value === undefined) return null; + let carry = value; + for (let i = 0; i < bytes.length; i += 1) { + const next = bytes[i] * 58 + carry; + bytes[i] = next & 0xff; + carry = next >> 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + + for (const char of value) { + if (char !== "1") break; + bytes.push(0); + } + + return new Uint8Array(bytes.reverse()); +} + +function ss58PublicKeyHex(address: string): string | null { + const decoded = decodeBase58(address); + if (!decoded) return null; + const prefixLength = decoded[0] < 64 ? 1 : decoded[0] < 128 ? 2 : 0; + if (prefixLength === 0) return null; + const keyStart = prefixLength; + const keyEnd = keyStart + 32; + if (decoded.length < keyEnd + 1) return null; + return bytesToHex(decoded.slice(keyStart, keyEnd)); +} + +export function addressIdentityKey(address: string | null | undefined): string { + const normalized = (address ?? "").trim(); + if (!normalized) return ""; + return ss58PublicKeyHex(normalized) ?? normalized.toLowerCase(); +} + +export function addressesReferToSameIdentity( + left: string | null | undefined, + right: string | null | undefined, +): boolean { + const leftKey = addressIdentityKey(left); + const rightKey = addressIdentityKey(right); + return Boolean(leftKey && rightKey && leftKey === rightKey); +} diff --git a/src/lib/proposalVetoUi.ts b/src/lib/proposalVetoUi.ts new file mode 100644 index 0000000..47bf124 --- /dev/null +++ b/src/lib/proposalVetoUi.ts @@ -0,0 +1,9 @@ +export function calculateCitizenVetoSupportPercent(input: { + vetoVotes: number; + eligibleCitizens: number; +}): number { + const eligibleCitizens = Math.max(0, input.eligibleCitizens); + if (eligibleCitizens === 0) return 0; + const vetoVotes = Math.max(0, input.vetoVotes); + return Math.round((vetoVotes / eligibleCitizens) * 100); +} diff --git a/src/pages/chambers/Chamber.tsx b/src/pages/chambers/Chamber.tsx index 3d1bd24..134bcc5 100644 --- a/src/pages/chambers/Chamber.tsx +++ b/src/pages/chambers/Chamber.tsx @@ -47,6 +47,7 @@ import { formatDate, formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { useAuth } from "@/app/auth/AuthContext"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; const Chamber: React.FC = () => { const { id } = useParams(); @@ -234,7 +235,9 @@ const Chamber: React.FC = () => { const isMember = useMemo(() => { if (!address || !data) return false; - return data.governors.some((gov) => gov.id === address); + return data.governors.some((gov) => + addressesReferToSameIdentity(gov.id, address), + ); }, [address, data]); const canWrite = useMemo(() => { diff --git a/src/pages/factions/Faction.tsx b/src/pages/factions/Faction.tsx index edeaf30..ffae931 100644 --- a/src/pages/factions/Faction.tsx +++ b/src/pages/factions/Faction.tsx @@ -31,14 +31,11 @@ import { apiMe, getApiErrorPayload, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import type { FactionDto } from "@/types/api"; -function normalizeAddress(value: string): string { - return value.trim().toLowerCase(); -} - const Faction: React.FC = () => { const { id } = useParams(); const [searchParams] = useSearchParams(); @@ -96,10 +93,8 @@ const Faction: React.FC = () => { const viewerMembership = useMemo(() => { if (!viewerAddress) return null; - return memberships.find( - (membership) => - normalizeAddress(membership.address) === - normalizeAddress(viewerAddress), + return memberships.find((membership) => + addressesReferToSameIdentity(membership.address, viewerAddress), ); }, [memberships, viewerAddress]); @@ -389,8 +384,10 @@ const Faction: React.FC = () => { .map((membership) => { const isSelf = viewerAddress !== null && - normalizeAddress(viewerAddress) === - normalizeAddress(membership.address); + addressesReferToSameIdentity( + viewerAddress, + membership.address, + ); return (
{ const { id, channelId, threadId } = useParams(); const [faction, setFaction] = useState(null); @@ -64,10 +61,8 @@ const FactionChannel: React.FC = () => { const viewerMembership = useMemo(() => { if (!viewerAddress) return null; - return memberships.find( - (membership) => - normalizeAddress(membership.address) === - normalizeAddress(viewerAddress), + return memberships.find((membership) => + addressesReferToSameIdentity(membership.address, viewerAddress), ); }, [memberships, viewerAddress]); diff --git a/src/pages/factions/FactionInitiative.tsx b/src/pages/factions/FactionInitiative.tsx index 3eef7ec..4439267 100644 --- a/src/pages/factions/FactionInitiative.tsx +++ b/src/pages/factions/FactionInitiative.tsx @@ -17,14 +17,11 @@ import { apiMe, getApiErrorPayload, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import type { FactionDto } from "@/types/api"; -function normalizeAddress(value: string): string { - return value.trim().toLowerCase(); -} - const FactionInitiative: React.FC = () => { const { id, initiativeId } = useParams(); const [faction, setFaction] = useState(null); @@ -60,10 +57,8 @@ const FactionInitiative: React.FC = () => { const viewerMembership = useMemo(() => { if (!viewerAddress) return null; - return memberships.find( - (membership) => - normalizeAddress(membership.address) === - normalizeAddress(viewerAddress), + return memberships.find((membership) => + addressesReferToSameIdentity(membership.address, viewerAddress), ); }, [memberships, viewerAddress]); @@ -73,10 +68,12 @@ const FactionInitiative: React.FC = () => { viewerMembership?.role === "steward"; const initiatives = useMemo(() => { - const viewer = normalizeAddress(viewerAddress ?? ""); return initiativesRaw.filter((initiative) => { if (initiative.status !== "draft") return true; - return normalizeAddress(initiative.ownerAddress) === viewer; + return addressesReferToSameIdentity( + initiative.ownerAddress, + viewerAddress, + ); }); }, [initiativesRaw, viewerAddress]); @@ -87,9 +84,9 @@ const FactionInitiative: React.FC = () => { const canEditActiveInitiative = useMemo(() => { if (!activeInitiative) return false; if (canModerate) return true; - return ( - normalizeAddress(activeInitiative.ownerAddress) === - normalizeAddress(viewerAddress ?? "") + return addressesReferToSameIdentity( + activeInitiative.ownerAddress, + viewerAddress, ); }, [activeInitiative, canModerate, viewerAddress]); diff --git a/src/pages/factions/FactionInitiativeCreate.tsx b/src/pages/factions/FactionInitiativeCreate.tsx index cbcd4da..0f8c0ec 100644 --- a/src/pages/factions/FactionInitiativeCreate.tsx +++ b/src/pages/factions/FactionInitiativeCreate.tsx @@ -15,13 +15,10 @@ import { apiMe, getApiErrorPayload, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; import type { FactionDto } from "@/types/api"; -function normalizeAddress(value: string): string { - return value.trim().toLowerCase(); -} - const FactionInitiativeCreate: React.FC = () => { const { id } = useParams(); const navigate = useNavigate(); @@ -62,8 +59,7 @@ const FactionInitiativeCreate: React.FC = () => { return (faction.memberships ?? []).some( (membership) => membership.isActive && - normalizeAddress(membership.address) === - normalizeAddress(viewerAddress), + addressesReferToSameIdentity(membership.address, viewerAddress), ); }, [faction, viewerAddress]); diff --git a/src/pages/factions/FactionThreadCreate.tsx b/src/pages/factions/FactionThreadCreate.tsx index 3691d33..9e5e6ec 100644 --- a/src/pages/factions/FactionThreadCreate.tsx +++ b/src/pages/factions/FactionThreadCreate.tsx @@ -15,13 +15,10 @@ import { apiMe, getApiErrorPayload, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; import type { FactionDto } from "@/types/api"; -function normalizeAddress(value: string): string { - return value.trim().toLowerCase(); -} - const FactionThreadCreate: React.FC = () => { const { id, channelId } = useParams(); const navigate = useNavigate(); @@ -61,8 +58,8 @@ const FactionThreadCreate: React.FC = () => { return ( (faction.memberships ?? []).find( (membership) => - normalizeAddress(membership.address) === - normalizeAddress(viewerAddress) && membership.isActive, + addressesReferToSameIdentity(membership.address, viewerAddress) && + membership.isActive, ) ?? null ); }, [faction, viewerAddress]); diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 27dc339..372b71d 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { useAuth } from "@/app/auth/AuthContext"; import { Button } from "@/components/primitives/button"; import { PageHint } from "@/components/PageHint"; @@ -110,18 +111,17 @@ const urgentEntityKey = (item: FeedItemDto) => { return `id:${item.id}`; }; -const isUrgentItemInteractable = ( +export const isUrgentItemInteractable = ( item: FeedItemDto, isGovernorActive: boolean, viewerAddress?: string, ) => { if (item.actionable !== true) return false; if (item.stage === "build") { - const viewer = viewerAddress?.trim().toLowerCase(); - const proposer = (item.proposerId ?? item.proposer ?? "") - .trim() - .toLowerCase(); - return Boolean(viewer && proposer && viewer === proposer); + return addressesReferToSameIdentity( + viewerAddress, + item.proposerId ?? item.proposer, + ); } if ((item.stage === "pool" || item.stage === "vote") && !isGovernorActive) { if (item.href?.includes("/referendum")) return true; diff --git a/src/pages/human-nodes/HumanNode.tsx b/src/pages/human-nodes/HumanNode.tsx index 0d15c3e..6c5fc5b 100644 --- a/src/pages/human-nodes/HumanNode.tsx +++ b/src/pages/human-nodes/HumanNode.tsx @@ -21,6 +21,7 @@ import { apiHuman, apiMyGovernance, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; import type { GetMyGovernanceResponse, @@ -251,7 +252,7 @@ const HumanNode: React.FC = () => { ); const showShortBadge = !isAddressName && !isGenericName; - const isSelfProfile = Boolean(auth.address && profile.id === auth.address); + const isSelfProfile = addressesReferToSameIdentity(auth.address, profile.id); const handleDelegateHere = async (chamberId: string) => { if (!id) return; @@ -437,8 +438,10 @@ const HumanNode: React.FC = () => { {delegationCards.map((item) => { const viewerItem = viewerDelegationByChamber.get(item.chamberId) ?? null; - const viewerAlreadyDelegatesHere = - viewerItem?.delegateeAddress === profile.id; + const viewerAlreadyDelegatesHere = addressesReferToSameIdentity( + viewerItem?.delegateeAddress, + profile.id, + ); const canManage = !isSelfProfile && manageableChambers.some( diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx index bd4d303..f3e126e 100644 --- a/src/pages/proposals/ProposalChamber.tsx +++ b/src/pages/proposals/ProposalChamber.tsx @@ -20,6 +20,7 @@ import { apiProposalChamberPage, apiProposalTimeline, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; import { formatDateTime } from "@/lib/dateTime"; import type { @@ -161,9 +162,10 @@ const ProposalChamber: React.FC = () => { ? proposal.milestoneIndex : null; const referendumVote = proposal.voteKind === "referendum"; - const viewerIsProposer = - auth.address?.trim().toLowerCase() === - proposal.proposerId.trim().toLowerCase(); + const viewerIsProposer = addressesReferToSameIdentity( + auth.address, + proposal.proposerId, + ); const scoreLabel = proposal.scoreLabel === "MM" || milestoneVoteIndex !== null ? "MM" diff --git a/src/pages/proposals/ProposalChamberVeto.tsx b/src/pages/proposals/ProposalChamberVeto.tsx index 7a2ad41..3580cf8 100644 --- a/src/pages/proposals/ProposalChamberVeto.tsx +++ b/src/pages/proposals/ProposalChamberVeto.tsx @@ -15,6 +15,7 @@ import { apiProposalChamberVetoPage, apiProposalTimeline, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import type { @@ -116,9 +117,10 @@ const ProposalChamberVeto: React.FC = () => { ); } - const viewerIsProposer = - auth.address?.trim().toLowerCase() === - proposal.proposerId.trim().toLowerCase(); + const viewerIsProposer = addressesReferToSameIdentity( + auth.address, + proposal.proposerId, + ); const stageLinks = id ? { vote: proposal.voteRoute, diff --git a/src/pages/proposals/ProposalCitizenVeto.tsx b/src/pages/proposals/ProposalCitizenVeto.tsx index 9a375f2..af621a3 100644 --- a/src/pages/proposals/ProposalCitizenVeto.tsx +++ b/src/pages/proposals/ProposalCitizenVeto.tsx @@ -15,7 +15,9 @@ import { apiProposalCitizenVetoPage, apiProposalTimeline, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; +import { calculateCitizenVetoSupportPercent } from "@/lib/proposalVetoUi"; import type { CitizenVetoProposalPageDto, ProposalTimelineItemDto, @@ -120,13 +122,14 @@ const ProposalCitizenVeto: React.FC = () => { proposal.eligibleCitizens > 0 ? Math.round((castVotes / proposal.eligibleCitizens) * 100) : 0; - const vetoPercent = - proposal.eligibleCitizens > 0 - ? Math.round((proposal.votes.veto / proposal.eligibleCitizens) * 100) - : 0; - const viewerIsProposer = - auth.address?.trim().toLowerCase() === - proposal.proposerId.trim().toLowerCase(); + const vetoPercent = calculateCitizenVetoSupportPercent({ + vetoVotes: proposal.votes.veto, + eligibleCitizens: proposal.eligibleCitizens, + }); + const viewerIsProposer = addressesReferToSameIdentity( + auth.address, + proposal.proposerId, + ); const stageLinks = id ? { vote: proposal.voteRoute, diff --git a/src/pages/proposals/ProposalFormation.tsx b/src/pages/proposals/ProposalFormation.tsx index 35048b4..d4876d0 100644 --- a/src/pages/proposals/ProposalFormation.tsx +++ b/src/pages/proposals/ProposalFormation.tsx @@ -16,6 +16,7 @@ import { apiProposalFormationPage, apiProposalTimeline, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; import { useAuth } from "@/app/auth/AuthContext"; import type { @@ -123,10 +124,10 @@ const ProposalFormation: React.FC = () => { project.nextMilestoneIndex ?? (milestones.total > 0 ? milestones.filled + 1 : undefined); const pendingMilestone = project.pendingMilestoneIndex ?? undefined; - const viewerAddress = auth.address?.trim().toLowerCase(); - const proposerAddress = project.proposer.trim().toLowerCase(); - const isProposerViewer = - Boolean(viewerAddress) && viewerAddress === proposerAddress; + const isProposerViewer = addressesReferToSameIdentity( + auth.address, + project.proposer, + ); const canJoinProject = project.projectState !== "ready_to_finish" && project.projectState !== "completed" && diff --git a/src/pages/proposals/ProposalPP.tsx b/src/pages/proposals/ProposalPP.tsx index 426a61a..fcedbfa 100644 --- a/src/pages/proposals/ProposalPP.tsx +++ b/src/pages/proposals/ProposalPP.tsx @@ -19,6 +19,7 @@ import { apiProposalTimeline, getApiErrorPayload, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; import type { PoolProposalPageDto, ProposalTimelineItemDto } from "@/types/api"; import { useAuth } from "@/app/auth/AuthContext"; @@ -123,9 +124,10 @@ const ProposalPP: React.FC = () => { ? proposal.teamSlots.split("/").map((v) => Number(v.trim())) : [0, 0]; const openSlots = Math.max(totalSlots - filledSlots, 0); - const viewerIsProposer = - auth.address?.trim().toLowerCase() === - proposal.proposerId.trim().toLowerCase(); + const viewerIsProposer = addressesReferToSameIdentity( + auth.address, + proposal.proposerId, + ); const formationSummaryStats = proposal.formationEligible ? [ { label: "Budget ask", value: proposal.budget }, diff --git a/src/pages/proposals/ProposalReferendum.tsx b/src/pages/proposals/ProposalReferendum.tsx index 8fef22e..a3fcf86 100644 --- a/src/pages/proposals/ProposalReferendum.tsx +++ b/src/pages/proposals/ProposalReferendum.tsx @@ -17,6 +17,7 @@ import { apiProposalTimeline, apiReferendumVote, } from "@/lib/apiClient"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; import { formatLoadError } from "@/lib/errorFormatting"; import type { ChamberProposalPageDto, @@ -137,9 +138,10 @@ const ProposalReferendum: React.FC = () => { const yesPercentOfQuorum = engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0; const passingNeededPercent = 66.6; - const viewerIsProposer = - auth.address?.trim().toLowerCase() === - proposal.proposerId.trim().toLowerCase(); + const viewerIsProposer = addressesReferToSameIdentity( + auth.address, + proposal.proposerId, + ); const [filledSlots, totalSlots] = proposal.formationEligible ? proposal.teamSlots.split("/").map((v) => Number(v.trim())) diff --git a/tests/api/cross-repo-contract.test.js b/tests/api/cross-repo-contract.test.js new file mode 100644 index 0000000..d944fcb --- /dev/null +++ b/tests/api/cross-repo-contract.test.js @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { test } from "@rstest/core"; + +const webRoot = process.cwd(); +const workspaceRoot = resolve(webRoot, ".."); +const serverRoot = resolve(workspaceRoot, "vortex-simulator-server"); + +function readServer(path) { + return readFileSync(resolve(serverRoot, path), "utf8"); +} + +function readWeb(path) { + return readFileSync(resolve(webRoot, path), "utf8"); +} + +function unique(values) { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +function extractServerCommandTypes() { + const source = readServer("api/commandSchemas.ts"); + return new Set( + [...source.matchAll(/type:\s*z\.literal\("([^"]+)"\)/g)].map( + (match) => match[1], + ), + ); +} + +function extractClientCommandTypes() { + const source = readWeb("src/lib/apiClient.ts"); + return unique( + [...source.matchAll(/type:\s*"([^"]+)"/g)].map((match) => match[1]), + ).filter((type) => type.includes(".")); +} + +const resourceFilesByRouter = { + admin: "api/resources/admin.ts", + auth: "api/resources/auth.ts", + chambers: "api/resources/chambers.ts", + clock: "api/resources/clock.ts", + cm: "api/resources/cm.ts", + command: "api/resources/command.ts", + courts: "api/resources/courts.ts", + factions: "api/resources/factions.ts", + feed: "api/resources/feed.ts", + formation: "api/resources/formation.ts", + gate: "api/resources/gate.ts", + health: "api/resources/health.ts", + humans: "api/resources/humans.ts", + invision: "api/resources/invision.ts", + myGovernance: "api/resources/myGovernance.ts", + proposals: "api/resources/proposals.ts", +}; + +function normalizeRoute(path) { + const withoutQuery = path.replace(/\?.*$/, ""); + const withoutTemplateQuery = withoutQuery.replace( + /\$\{qs(?:\.toString\(\))?\}/g, + "", + ); + const withParams = withoutTemplateQuery + .replace(/\$\{[^}]+\}/g, ":param") + .replace(/:[A-Za-z0-9_]+/g, ":param"); + return withParams.replace(/\/$/, "") || "/"; +} + +function joinRoute(prefix, routePath) { + const normalizedPrefix = prefix.replace(/\/$/, ""); + if (routePath === "/") return normalizedPrefix; + return `${normalizedPrefix}${routePath.startsWith("/") ? routePath : `/${routePath}`}`; +} + +function extractServerRoutes() { + const apiSource = readServer("api/api.ts"); + const routes = new Set(); + + for (const match of apiSource.matchAll( + /api\.route\("([^"]+)",\s*([A-Za-z0-9_]+)\)/g, + )) { + const [, prefix, routerName] = match; + const file = resourceFilesByRouter[routerName]; + assert.ok(file, `Missing route parser mapping for router ${routerName}`); + const source = readServer(file); + const routeRegex = new RegExp(`${routerName}\\.\\w+\\("([^"]+)"`, "g"); + for (const routeMatch of source.matchAll(routeRegex)) { + routes.add(normalizeRoute(joinRoute(`/api${prefix}`, routeMatch[1]))); + } + } + + for (const match of apiSource.matchAll(/api\.\w+\("([^"]+)"/g)) { + routes.add(normalizeRoute(`/api${match[1]}`)); + } + + return routes; +} + +function extractClientRoutes() { + const source = readWeb("src/lib/apiClient.ts"); + const routes = []; + const callRegex = /api(?:Get|Post)(?:<[^>]+>)?\(\s*([`'"])([\s\S]*?)\1/g; + for (const match of source.matchAll(callRegex)) { + const path = match[2].trim(); + if (path.startsWith("/api/")) routes.push(normalizeRoute(path)); + } + return unique(routes); +} + +test("web command client only emits command types accepted by the server schema", () => { + const serverTypes = extractServerCommandTypes(); + const clientTypes = extractClientCommandTypes(); + const missing = clientTypes.filter((type) => !serverTypes.has(type)); + assert.deepEqual(missing, []); +}); + +test("web API client only calls routes exposed by the server router", () => { + const serverRoutes = extractServerRoutes(); + const clientRoutes = extractClientRoutes(); + const missing = clientRoutes.filter((route) => !serverRoutes.has(route)); + assert.deepEqual(missing, []); +}); diff --git a/tests/unit/address-identity.test.ts b/tests/unit/address-identity.test.ts new file mode 100644 index 0000000..cccd2b8 --- /dev/null +++ b/tests/unit/address-identity.test.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@rstest/core"; + +import { + addressIdentityKey, + addressesReferToSameIdentity, +} from "../../src/lib/addressIdentity"; + +const genericAddress = "5C62Ck4UrFPiBtoCmeSrgF7x9yv9mn38446dhCpsi2mLHiFT"; +const canonicalAddress = "hmnVXRhJsFLh5CbdxZNrn5Lu6FR2nDacxgSLrsVoyoW9ERXAP"; + +test("addressIdentityKey resolves same-key SS58 encodings", () => { + expect(addressIdentityKey(genericAddress)).toBe( + addressIdentityKey(canonicalAddress), + ); +}); + +test("addressesReferToSameIdentity falls back to case-insensitive string identity", () => { + expect(addressesReferToSameIdentity("0xAlice", "0xalice")).toBe(true); + expect(addressesReferToSameIdentity("0xAlice", "0xBob")).toBe(false); + expect(addressesReferToSameIdentity("", "0xBob")).toBe(false); +}); diff --git a/tests/unit/feed-urgent.test.ts b/tests/unit/feed-urgent.test.ts new file mode 100644 index 0000000..cdb74e3 --- /dev/null +++ b/tests/unit/feed-urgent.test.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@rstest/core"; + +import { isUrgentItemInteractable } from "../../src/pages/feed/Feed"; +import type { FeedItemDto } from "../../src/types/api"; + +const genericAddress = "5C62Ck4UrFPiBtoCmeSrgF7x9yv9mn38446dhCpsi2mLHiFT"; +const canonicalAddress = "hmnVXRhJsFLh5CbdxZNrn5Lu6FR2nDacxgSLrsVoyoW9ERXAP"; + +const buildItem: FeedItemDto = { + id: "feed:build", + title: "Build action", + meta: "Formation", + stage: "build", + summary: "Build action", + summaryPill: "Build", + timestamp: "2026-01-01T00:00:00.000Z", + href: "/app/proposals/build/formation", + actionable: true, + proposerId: canonicalAddress, +}; + +test("urgent build feed actionability resolves same-key proposer variants", () => { + expect(isUrgentItemInteractable(buildItem, false, genericAddress)).toBe(true); + expect(isUrgentItemInteractable(buildItem, false, "hmptest-other")).toBe( + false, + ); +}); diff --git a/tests/unit/proposal-veto-ui.test.ts b/tests/unit/proposal-veto-ui.test.ts new file mode 100644 index 0000000..9bf82c7 --- /dev/null +++ b/tests/unit/proposal-veto-ui.test.ts @@ -0,0 +1,28 @@ +import { test, expect } from "@rstest/core"; + +import { calculateCitizenVetoSupportPercent } from "../../src/lib/proposalVetoUi"; + +test("citizen veto support percent uses eligible Citizens as denominator", () => { + expect( + calculateCitizenVetoSupportPercent({ + vetoVotes: 1, + eligibleCitizens: 3, + }), + ).toBe(33); + + expect( + calculateCitizenVetoSupportPercent({ + vetoVotes: 2, + eligibleCitizens: 3, + }), + ).toBe(67); +}); + +test("citizen veto support percent handles an empty electorate", () => { + expect( + calculateCitizenVetoSupportPercent({ + vetoVotes: 1, + eligibleCitizens: 0, + }), + ).toBe(0); +});