diff --git a/.gitignore b/.gitignore index 0d25e91..8f4b088 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,7 @@ coverage/ *.webmanifest # Auto-generated version file -src/version.js \ No newline at end of file +src/version.js + +# Planning files +STATE_MACHINE_PLAN.md \ No newline at end of file diff --git a/README.md b/README.md index 5692ff2..92125cd 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ The app is optimized for static hosting and works great with services like Nginx ```text src/ -├── App.jsx # Main application component +├── ExpenseForm.jsx # Main expense form component ├── AppContext.jsx # Global application context ├── AuthProvider.jsx # SettleUp authentication ├── MainFormPage.jsx # Main form interface diff --git a/package-lock.json b/package-lock.json index 2ac7e17..a243799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", + "@xstate/react": "^6.0.0", "axios": "^1.9.0", "firebase": "^11.10.0", "framer-motion": "^12.18.1", @@ -19,6 +20,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.9.4", "react-transition-group": "^4.4.5", + "xstate": "^5.25.1", "ynab": "^2.9.0" }, "devDependencies": { @@ -5039,6 +5041,25 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xstate/react": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-6.0.0.tgz", + "integrity": "sha512-xXlLpFJxqLhhmecAXclBECgk+B4zYSrDTl8hTfPZBogkn82OHKbm9zJxox3Z/YXoOhAQhKFTRLMYGdlbhc6T9A==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.2", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "xstate": "^5.20.0" + }, + "peerDependenciesMeta": { + "xstate": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -11350,6 +11371,29 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", @@ -13399,6 +13443,16 @@ "dev": true, "license": "MIT" }, + "node_modules/xstate": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.25.1.tgz", + "integrity": "sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7916110..1334001 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", + "@xstate/react": "^6.0.0", "axios": "^1.9.0", "firebase": "^11.10.0", "framer-motion": "^12.18.1", @@ -34,6 +35,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.9.4", "react-transition-group": "^4.4.5", + "xstate": "^5.25.1", "ynab": "^2.9.0" }, "devDependencies": { diff --git a/src/App.jsx b/src/ExpenseForm.jsx similarity index 99% rename from src/App.jsx rename to src/ExpenseForm.jsx index 3a490d4..8624424 100644 --- a/src/App.jsx +++ b/src/ExpenseForm.jsx @@ -20,7 +20,7 @@ import { formStatePropType } from "./propTypes.js"; import { getClosestLocation } from "./utils/ynabUtils.js"; import "./index.css"; -export default function App({ onSubmit, formState, setFormState }) { +export default function ExpenseForm({ onSubmit, formState, setFormState }) { const { ynabAPI, budgetId, setAccounts } = useAppContext(); const { token: settleUpToken, user: settleUpUser } = useAuth(); const [payees, setPayees] = useState([]); @@ -429,8 +429,8 @@ export default function App({ onSubmit, formState, setFormState }) { ); } -App.propTypes = { +ExpenseForm.propTypes = { onSubmit: PropTypes.func.isRequired, formState: formStatePropType.isRequired, setFormState: PropTypes.func.isRequired, -}; +}; \ No newline at end of file diff --git a/src/MainFormPage.jsx b/src/MainFormPage.jsx index cfe0179..0acb44b 100644 --- a/src/MainFormPage.jsx +++ b/src/MainFormPage.jsx @@ -1,7 +1,7 @@ import PropTypes from "prop-types"; import { useState } from "react"; -import App from "./App.jsx"; +import ExpenseForm from "./ExpenseForm.jsx"; import { useAuth } from "./AuthProvider.jsx"; import CenteredCardLayout from "./components/CenteredCardLayout.jsx"; import { formStatePropType } from "./propTypes"; @@ -35,7 +35,7 @@ export default function MainFormPage({ Logout - setShowReview(true)} formState={formState} setFormState={setFormState} diff --git a/src/StateMachineTest.jsx b/src/StateMachineTest.jsx new file mode 100644 index 0000000..1cc2ea5 --- /dev/null +++ b/src/StateMachineTest.jsx @@ -0,0 +1,395 @@ +import { useMachine } from "@xstate/react"; +import React from "react"; +import { fromPromise } from "xstate"; + +import ApiSyncStepper from "./components/ApiSyncStepper.jsx"; +import { apiSyncStateMachine } from "./machines/apiSyncStateMachine"; + +function StateMachineTest() { + const [ynabResolver, setYnabResolver] = React.useState(null); + const [settleupResolver, setSettleupResolver] = React.useState(null); + + // Create mock formState objects for testing + const createMockFormState = (target) => ({ + amountMilliunits: 25000, // $25.00 + description: "Test Transaction", + target, + account: { bourso: true, swile: false }, + payee: "Test Merchant", + payeeId: "test-payee-id", + category: "Groceries", + categoryId: "test-category-id", + date: new Date(), + settleUpCategory: "Food", + settleUpGroups: null, + settleUpGroup: null, + settleUpPayerId: "", + settleUpMembers: [], + settleUpCurrency: "EUR", + swileMilliunits: 25000, + showAccounts: false, + showDetails: false, + }); + + // Add debugging + React.useEffect(() => { + console.log("ynabResolver updated:", ynabResolver); + }, [ynabResolver]); + + React.useEffect(() => { + console.log("settleupResolver updated:", settleupResolver); + }, [settleupResolver]); + + // Create machine with proper actor override using .provide() + const machineWithActors = React.useMemo(() => { + return apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => { + console.log("YNAB service started - waiting for user input"); + return new Promise((resolve, reject) => { + setYnabResolver({ resolve, reject }); + }); + }), + submitSettleup: fromPromise(() => { + console.log("SettleUp service started - waiting for user input"); + return new Promise((resolve, reject) => { + setSettleupResolver({ resolve, reject }); + }); + }), + }, + }); + }, [setYnabResolver, setSettleupResolver]); + + const [state, send] = useMachine(machineWithActors); + + // Add state change debugging + React.useEffect(() => { + console.log("State changed:", state.value, "Context:", state.context); + }, [state]); + + // Helper function to get current state path + const getStatePath = (state) => { + const paths = []; + if (state.matches("idle")) paths.push("idle"); + if (state.matches("success")) paths.push("success"); + if (state.matches("error")) paths.push("error"); + if (state.matches("partialSuccess")) paths.push("partialSuccess"); + + if (state.matches("syncing")) { + paths.push("syncing"); + + // Check YNAB substate + if (state.matches({ syncing: { ynab: "checking" } })) + paths.push("syncing.ynab.checking"); + if (state.matches({ syncing: { ynab: "skipped" } })) + paths.push("syncing.ynab.skipped"); + if (state.matches({ syncing: { ynab: "submitting" } })) + paths.push("syncing.ynab.submitting"); + if (state.matches({ syncing: { ynab: "success" } })) + paths.push("syncing.ynab.success"); + if (state.matches({ syncing: { ynab: "error" } })) + paths.push("syncing.ynab.error"); + + // Check SettleUp substate + if (state.matches({ syncing: { settleup: "checking" } })) + paths.push("syncing.settleup.checking"); + if (state.matches({ syncing: { settleup: "skipped" } })) + paths.push("syncing.settleup.skipped"); + if (state.matches({ syncing: { settleup: "submitting" } })) + paths.push("syncing.settleup.submitting"); + if (state.matches({ syncing: { settleup: "success" } })) + paths.push("syncing.settleup.success"); + if (state.matches({ syncing: { settleup: "error" } })) + paths.push("syncing.settleup.error"); + } + + return paths; + }; + + return ( +
+

State Machine Tester

+ + {/* Current State */} +
+

Current States:

+
    + {getStatePath(state).map((path) => ( +
  • + {path} +
  • + ))} +
+
+ + {/* API Sync Stepper */} + + + {/* Start Controls */} +
+

Start Sync:

+
+ + + +
+
+ + {/* API Control Buttons */} +
+

Control API Outcomes:

+ + {/* YNAB Controls */} +
+ YNAB API: + + + {ynabResolver && ( + + ⏳ Waiting... + + )} +
+ + {/* SettleUp Controls */} +
+ SettleUp API: + + + {settleupResolver && ( + + ⏳ Waiting... + + )} +
+
+ + {/* Final State Controls */} +
+

Final State Actions:

+ + + +
+ + {/* Context Display */} +
+

Context:

+
{JSON.stringify(state.context, null, 2)}
+
+
+ ); +} + +export default StateMachineTest; diff --git a/src/components/ApiSyncStepper.jsx b/src/components/ApiSyncStepper.jsx new file mode 100644 index 0000000..e2e1b14 --- /dev/null +++ b/src/components/ApiSyncStepper.jsx @@ -0,0 +1,465 @@ +import PropTypes from "prop-types"; +import React from "react"; +import { MdCheck, MdClose, MdCircle, MdMoreHoriz } from "react-icons/md"; + +// Color constants +const COLORS = { + current: "#3b82f6", // blue + success: "#22c55e", // green + error: "#ef4444", // red + disabled: "#9ca3af", // gray + inactive: "#9ca3af", // same gray (but semantically different) +}; + +const ApiSyncStepper = ({ state, context }) => { + const currentState = state?.value || "idle"; + const formState = context?.formState; + const results = context?.results || {}; + const errors = context?.errors || {}; + + // Helper function for API step state logic (ynab/settleup) + const getApiStepState = (apiName) => { + if (!formState?.target?.[apiName]) return "disabled"; + + // Check if we're in any syncing state + if ( + currentState === "syncing" || + (typeof currentState === "object" && currentState.syncing) + ) { + // Get nested state from syncing structure + const apiState = state?.value?.syncing?.[apiName]; + if (apiState === "submitting") return "current"; + if (apiState === "success") return "success"; + if (apiState === "error") return "error"; + // If API is targeted and we're syncing, show as current + return "current"; + } + if (results[apiName] && !errors[apiName]) return "success"; + if (errors[apiName]) return "error"; + return "inactive"; + }; + + // Simplified step state determination + const getStepState = (stepName) => { + switch (stepName) { + case "entry": + return currentState === "idle" ? "current" : "success"; + + case "complete": + return ["success", "error", "partialSuccess"].includes(currentState) + ? currentState === "success" + ? "success" + : currentState === "error" + ? "error" + : "inactive" + : "inactive"; + + default: // ynab or settleup + return getApiStepState(stepName); + } + }; + + const steps = { + entry: getStepState("entry"), + ynab: getStepState("ynab"), + settleup: getStepState("settleup"), + complete: getStepState("complete"), + }; + + // Simplified color logic + const getColor = (stepState) => { + return COLORS[stepState] || COLORS.inactive; + }; + + const getOpacity = (stepState) => { + return stepState === "disabled" ? 0.5 : 1; + }; + + // Line color based on destination step + const getLineColor = (endStepState, branchIsDisabled = false) => { + // If on a disabled branch, use disabled color + if (branchIsDisabled) return getColor("disabled"); + // Otherwise use the destination step's color + return getColor(endStepState); + }; + + const StepIcon = ({ stepState, size = 24 }) => { + const iconProps = { size, color: "white" }; + + switch (stepState) { + case "success": + return ; + case "error": + return ; + case "current": + return ; + default: + return ; + } + }; + + StepIcon.propTypes = { + stepState: PropTypes.string, + size: PropTypes.number, + }; + + const StepCircle = ({ stepState, label, x, y }) => { + const color = getColor(stepState); + // Only apply disabled opacity to API steps after entry completes + const isApiStep = label === "YNAB" || label === "SettleUp"; + const opacity = + isApiStep && steps.entry !== "success" ? 1 : getOpacity(stepState); + + return ( + + {/* Pulse animation for current state */} + {stepState === "current" && ( + + + + + )} + + {/* Main circle */} + + + {/* Icon using foreignObject to embed React component */} + +
+ +
+
+ + {/* Label */} + + {label} + +
+ ); + }; + + StepCircle.propTypes = { + stepState: PropTypes.string, + label: PropTypes.string, + x: PropTypes.number, + y: PropTypes.number, + }; + + // Helper function to create path elements + const createPath = (d, stroke, strokeWidth = 3) => ( + + ); + + const createLine = (x1, y1, x2, y2, stroke, strokeWidth = 3) => ( + + ); + + // SVG layout configuration + const layout = { + width: 800, + height: 280, + entryX: 100, + splitX: 220, + branchX: 400, + joinX: 580, + completeX: 700, + centerY: 140, + ynabY: 80, + settleupY: 200, + cornerRadius: 15, + }; + + // Calculate line colors based on destination steps + const getEntryToSplitColor = () => { + const activeBranches = [steps.ynab, steps.settleup].filter( + (step) => step !== "disabled", + ); + + // If no active branches, use disabled color + if (activeBranches.length === 0) return getColor("disabled"); + + // If only one active branch, use its color + if (activeBranches.length === 1) return getColor(activeBranches[0]); + + // Both active - return color by priority: error > current > success > inactive + const statePriority = ["error", "current", "success", "inactive"]; + + for (const state of statePriority) { + if (activeBranches.includes(state)) return getColor(state); + } + + return getColor(steps.entry); // fallback + }; + + const entryToSplitColor = getEntryToSplitColor(); + const ynabBranchColor = getLineColor(steps.ynab, steps.ynab === "disabled"); + const settleupBranchColor = getLineColor( + steps.settleup, + steps.settleup === "disabled", + ); + const ynabToJoinColor = getLineColor( + steps.complete, + steps.ynab === "disabled", + ); + const settleupToJoinColor = getLineColor( + steps.complete, + steps.settleup === "disabled", + ); + const joinToCompleteColor = getLineColor(steps.complete); + + // Extract error information + const errorList = []; + ["ynab", "settleup"].forEach((api) => { + const error = context?.errors?.[api]; + if (error) { + errorList.push({ + api: api === "ynab" ? "YNAB" : "SettleUp", + message: + typeof error === "string" ? error : error.message || "Unknown error", + }); + } + }); + + return ( +
+ + {/* Main horizontal line from Entry to split */} + {createLine( + layout.entryX + 20, + layout.centerY, + layout.splitX - layout.cornerRadius, + layout.centerY, + entryToSplitColor, + )} + + {/* YNAB branch */} + + {/* Split to YNAB - use YNAB color for the corner */} + {createPath( + `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} + Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY - layout.cornerRadius}`, + ynabBranchColor, + )} + {createLine( + layout.splitX, + layout.centerY - layout.cornerRadius, + layout.splitX, + layout.ynabY + layout.cornerRadius, + ynabBranchColor, + )} + {createPath( + `M ${layout.splitX} ${layout.ynabY + layout.cornerRadius} + Q ${layout.splitX} ${layout.ynabY}, ${layout.splitX + layout.cornerRadius} ${layout.ynabY}`, + ynabBranchColor, + )} + {createLine( + layout.splitX + layout.cornerRadius, + layout.ynabY, + layout.branchX - 20, + layout.ynabY, + ynabBranchColor, + )} + + {/* YNAB to join - use branch-to-join color */} + {createLine( + layout.branchX + 20, + layout.ynabY, + layout.joinX - layout.cornerRadius, + layout.ynabY, + ynabToJoinColor, + )} + {createPath( + `M ${layout.joinX - layout.cornerRadius} ${layout.ynabY} + Q ${layout.joinX} ${layout.ynabY}, ${layout.joinX} ${layout.ynabY + layout.cornerRadius}`, + ynabToJoinColor, + )} + {createLine( + layout.joinX, + layout.ynabY + layout.cornerRadius, + layout.joinX, + layout.centerY - layout.cornerRadius, + ynabToJoinColor, + )} + {/* Corner from YNAB vertical to center horizontal */} + {createPath( + `M ${layout.joinX} ${layout.centerY - layout.cornerRadius} + Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, + ynabToJoinColor, + )} + + + {/* SettleUp branch */} + + {/* Split to SettleUp - use SettleUp color for the corner */} + {createPath( + `M ${layout.splitX - layout.cornerRadius} ${layout.centerY} + Q ${layout.splitX} ${layout.centerY}, ${layout.splitX} ${layout.centerY + layout.cornerRadius}`, + settleupBranchColor, + )} + {createLine( + layout.splitX, + layout.centerY + layout.cornerRadius, + layout.splitX, + layout.settleupY - layout.cornerRadius, + settleupBranchColor, + )} + {createPath( + `M ${layout.splitX} ${layout.settleupY - layout.cornerRadius} + Q ${layout.splitX} ${layout.settleupY}, ${layout.splitX + layout.cornerRadius} ${layout.settleupY}`, + settleupBranchColor, + )} + {createLine( + layout.splitX + layout.cornerRadius, + layout.settleupY, + layout.branchX - 20, + layout.settleupY, + settleupBranchColor, + )} + + {/* SettleUp to join - use branch-to-join color */} + {createLine( + layout.branchX + 20, + layout.settleupY, + layout.joinX - layout.cornerRadius, + layout.settleupY, + settleupToJoinColor, + )} + {createPath( + `M ${layout.joinX - layout.cornerRadius} ${layout.settleupY} + Q ${layout.joinX} ${layout.settleupY}, ${layout.joinX} ${layout.settleupY - layout.cornerRadius}`, + settleupToJoinColor, + )} + {createLine( + layout.joinX, + layout.settleupY - layout.cornerRadius, + layout.joinX, + layout.centerY + layout.cornerRadius, + settleupToJoinColor, + )} + {/* Corner from SettleUp vertical to center horizontal */} + {createPath( + `M ${layout.joinX} ${layout.centerY + layout.cornerRadius} + Q ${layout.joinX} ${layout.centerY}, ${layout.joinX + layout.cornerRadius} ${layout.centerY}`, + settleupToJoinColor, + )} + + + {/* Final line to complete */} + {createLine( + layout.joinX + layout.cornerRadius, + layout.centerY, + layout.completeX - 20, + layout.centerY, + joinToCompleteColor, + )} + + {/* Step circles */} + + + + + + + {/* Error Display */} + {errorList.length > 0 && ( +
+

Errors

+ {errorList.map((error, idx) => ( +
+ {error.api}: {error.message} +
+ ))} +
+ )} +
+ ); +}; + +ApiSyncStepper.propTypes = { + state: PropTypes.shape({ + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + syncing: PropTypes.shape({ + ynab: PropTypes.string, + settleup: PropTypes.string, + }), + }), + ]), + }), + context: PropTypes.shape({ + formState: PropTypes.shape({ + target: PropTypes.shape({ + ynab: PropTypes.bool, + settleup: PropTypes.bool, + }), + }), + results: PropTypes.object, + errors: PropTypes.object, + }), +}; + +export default ApiSyncStepper; diff --git a/src/components/ApiSyncStepper.test.jsx b/src/components/ApiSyncStepper.test.jsx new file mode 100644 index 0000000..6ff33bd --- /dev/null +++ b/src/components/ApiSyncStepper.test.jsx @@ -0,0 +1,379 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import ApiSyncStepper from "./ApiSyncStepper"; + +describe("ApiSyncStepper", () => { + const mockFormState = { + target: { + ynab: true, + settleup: true, + }, + }; + + const createMockState = (stateValue) => ({ value: stateValue }); + const createMockContext = ( + formState = mockFormState, + results = {}, + errors = {}, + ) => ({ + formState, + results, + errors, + }); + + describe("Step State Logic", () => { + test("should show entry as current when in idle state", () => { + const state = createMockState("idle"); + const context = createMockContext(); + + render(); + + // Entry should be visible + expect(screen.getByText("Entry")).toBeInTheDocument(); + }); + + test("should show entry as success when not in idle state", () => { + const state = createMockState("syncing"); + const context = createMockContext(); + + render(); + + // Should render without crashing and show all labels + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle disabled API steps when not targeted", () => { + const state = createMockState("idle"); + const context = createMockContext({ + target: { ynab: false, settleup: false }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should show API steps as current when syncing", () => { + const state = createMockState({ + syncing: { ynab: "submitting", settleup: "submitting" }, + }); + const context = createMockContext(); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should show API steps as success when completed successfully", () => { + const state = createMockState("syncing"); + const context = createMockContext(mockFormState, { + ynab: { success: true }, + settleup: { success: true }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should show API steps as error when failed", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { ynab: "API Error", settleup: "Network Error" }, + ); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + }); + + describe("Nested State Handling", () => { + test("should handle complex nested state structures", () => { + const state = { + value: { + syncing: { + ynab: "success", + settleup: "error", + }, + }, + }; + const context = createMockContext(); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + }); + + describe("Complete Step Logic", () => { + test("should show complete as success when in success state", () => { + const state = createMockState("success"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should show complete as error when in error state", () => { + const state = createMockState("error"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should show complete as inactive when in partial success", () => { + const state = createMockState("partialSuccess"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should show complete as inactive when not in final state", () => { + const state = createMockState("syncing"); + const context = createMockContext(); + + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("Error Display", () => { + test("should display errors when present", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { + ynab: "YNAB API connection failed", + settleup: "Invalid SettleUp credentials", + }, + ); + + render(); + + expect(screen.getByText("Errors")).toBeInTheDocument(); + expect(screen.getByText("YNAB:")).toBeInTheDocument(); + expect(screen.getByText("YNAB API connection failed")).toBeInTheDocument(); + expect(screen.getByText("SettleUp:")).toBeInTheDocument(); + expect( + screen.getByText("Invalid SettleUp credentials"), + ).toBeInTheDocument(); + }); + + test("should handle error objects with message property", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { + ynab: { message: "Connection timeout" }, + settleup: { message: "Rate limit exceeded" }, + }, + ); + + render(); + + expect(screen.getByText("Connection timeout")).toBeInTheDocument(); + expect(screen.getByText("Rate limit exceeded")).toBeInTheDocument(); + }); + + test("should handle error objects without message property", () => { + const state = createMockState("error"); + const context = createMockContext( + mockFormState, + {}, + { + ynab: { code: 500 }, + settleup: { status: "failed" }, + }, + ); + + render(); + + // Should show "Unknown error" for both APIs since they lack message property + const unknownErrors = screen.getAllByText("Unknown error"); + expect(unknownErrors).toHaveLength(2); + }); + + test("should not display error section when no errors", () => { + const state = createMockState("syncing"); + const context = createMockContext(); + + render(); + + expect(screen.queryByText("Errors")).not.toBeInTheDocument(); + }); + }); + + describe("Edge Cases and Null Handling", () => { + test("should handle null state gracefully", () => { + const context = createMockContext(); + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle undefined state gracefully", () => { + const context = createMockContext(); + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle null context gracefully", () => { + const state = createMockState("idle"); + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle missing formState gracefully", () => { + const state = createMockState("idle"); + const context = { results: {}, errors: {} }; + + render(); + + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + test("should handle missing target configuration", () => { + const state = createMockState("syncing"); + const context = createMockContext({ target: null }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + }); + + describe("Mixed Scenarios", () => { + test("should handle only YNAB enabled", () => { + const state = createMockState("syncing"); + const context = createMockContext({ + target: { ynab: true, settleup: false }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should handle only SettleUp enabled", () => { + const state = createMockState("syncing"); + const context = createMockContext({ + target: { ynab: false, settleup: true }, + }); + + render(); + + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + }); + + test("should handle partial success with some errors", () => { + const state = createMockState("partialSuccess"); + const context = createMockContext( + mockFormState, + { ynab: { success: true } }, + { settleup: "Failed to submit" }, + ); + + render(); + + expect(screen.getByText("Errors")).toBeInTheDocument(); + expect(screen.getByText("SettleUp:")).toBeInTheDocument(); + expect(screen.getByText("Failed to submit")).toBeInTheDocument(); + }); + + test("should handle unknown step names gracefully", () => { + const state = createMockState("unknownState"); + const context = createMockContext(); + + render(); + + // Should still render all expected steps + expect(screen.getByText("Entry")).toBeInTheDocument(); + expect(screen.getByText("YNAB")).toBeInTheDocument(); + expect(screen.getByText("SettleUp")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("SVG Rendering", () => { + test("should render SVG with correct structure and animations", () => { + const state = createMockState({ + syncing: { ynab: "submitting", settleup: "idle" }, + }); + const context = createMockContext(); + + render(); + + // Check SVG structure + const svg = document.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("viewBox", "0 0 800 280"); + expect(svg).toHaveClass("w-full", "h-auto"); + + // Check that SVG elements are present + const circles = document.querySelectorAll("circle"); + expect(circles.length).toBeGreaterThan(0); + + // Check for animations (pulse for current state) + const animations = document.querySelectorAll("animate"); + expect(animations.length).toBeGreaterThan(0); + + // Check for connecting elements + const lines = document.querySelectorAll("line"); + const paths = document.querySelectorAll("path"); + expect(lines.length).toBeGreaterThan(0); + expect(paths.length).toBeGreaterThan(0); + }); + }); + + describe("Component Structure", () => { + test("should render with correct container structure", () => { + const state = createMockState("idle"); + const context = createMockContext(); + + const { container } = render( + , + ); + + expect(container.firstChild).toHaveClass("w-full"); + }); + }); +}); \ No newline at end of file diff --git a/src/machines/apiSyncStateMachine.js b/src/machines/apiSyncStateMachine.js new file mode 100644 index 0000000..cae1110 --- /dev/null +++ b/src/machines/apiSyncStateMachine.js @@ -0,0 +1,260 @@ +import { createMachine, assign, fromPromise } from "xstate"; + +export const apiSyncStateMachine = createMachine( + { + id: "apiSync", + initial: "idle", + context: { + formState: null, // Complete transaction data + results: { ynab: null, settleup: null }, // API results + errors: { ynab: null, settleup: null }, // API errors + }, + states: { + idle: { + on: { + START_SYNC: { + target: "syncing", + actions: assign({ + formState: ({ event }) => event.formState, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + }, + }, + + syncing: { + type: "parallel", + states: { + ynab: { + initial: "checking", + states: { + checking: { + always: [ + { + target: "submitting", + guard: { + type: "ynabTargeted", + params: ({ context }) => ({ + formState: context.formState, + }), + }, + }, + { target: "skipped" }, + ], + }, + skipped: { + type: "final", + }, + submitting: { + invoke: { + id: "submitYnabActor", + src: "submitYnab", + onDone: { + target: "success", + actions: assign({ + results: ({ context, event }) => ({ + ...context.results, + ynab: event.output, + }), + }), + }, + onError: { + target: "error", + actions: assign({ + errors: ({ context, event }) => ({ + ...context.errors, + ynab: event.error, + }), + }), + }, + }, + }, + success: { + type: "final", + }, + error: { + type: "final", + }, + }, + }, + + settleup: { + initial: "checking", + states: { + checking: { + always: [ + { + target: "submitting", + guard: { + type: "settleupTargeted", + params: ({ context }) => ({ + formState: context.formState, + }), + }, + }, + { target: "skipped" }, + ], + }, + skipped: { + type: "final", + }, + submitting: { + invoke: { + id: "submitSettleupActor", + src: "submitSettleup", + onDone: { + target: "success", + actions: assign({ + results: ({ context, event }) => ({ + ...context.results, + settleup: event.output, + }), + }), + }, + onError: { + target: "error", + actions: assign({ + errors: ({ context, event }) => ({ + ...context.errors, + settleup: event.error, + }), + }), + }, + }, + }, + success: { + type: "final", + }, + error: { + type: "final", + }, + }, + }, + }, + onDone: [ + { + target: "success", + guard: { + type: "allTargetsSucceeded", + params: ({ context }) => ({ context }), + }, + }, + { + target: "partialSuccess", + guard: { + type: "partialTargetsSucceeded", + params: ({ context }) => ({ context }), + }, + }, + { target: "error" }, + ], + }, + + success: { + on: { + RESET: { + target: "idle", + actions: assign({ + formState: null, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + }, + }, + + partialSuccess: { + on: { + RETRY_FAILED: { + target: "syncing", + actions: assign({ + formState: ({ context }) => ({ + ...context.formState, + target: { + ynab: context.formState.target.ynab && !!context.errors.ynab, + settleup: + context.formState.target.settleup && + !!context.errors.settleup, + }, + }), + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + RESET: { + target: "idle", + actions: assign({ + formState: null, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + }, + }, + + error: { + on: { + RETRY: { + target: "syncing", + actions: assign({ + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + RESET: { + target: "idle", + actions: assign({ + formState: null, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }), + }, + }, + }, + }, + }, + { + guards: { + ynabTargeted: ({ context }) => context.formState?.target?.ynab || false, + settleupTargeted: ({ context }) => + context.formState?.target?.settleup || false, + allTargetsSucceeded: ({ context }) => { + // An API is "OK" if: not targeted OR (has result AND no error) + const ynabOk = + !context.formState?.target?.ynab || + (!!context.results.ynab && !context.errors.ynab); + const settleupOk = + !context.formState?.target?.settleup || + (!!context.results.settleup && !context.errors.settleup); + return ynabOk && settleupOk; + }, + partialTargetsSucceeded: ({ context }) => { + // Only check APIs that were actually targeted + const targetedApis = []; + const successfulApis = []; + + if (context.formState?.target?.ynab) { + targetedApis.push("ynab"); + if (context.results.ynab && !context.errors.ynab) { + successfulApis.push("ynab"); + } + } + + if (context.formState?.target?.settleup) { + targetedApis.push("settleup"); + if (context.results.settleup && !context.errors.settleup) { + successfulApis.push("settleup"); + } + } + + // Partial success: multiple targets AND some (but not all) succeeded + return ( + targetedApis.length > 1 && + successfulApis.length > 0 && + successfulApis.length < targetedApis.length + ); + }, + }, + }, +); diff --git a/src/machines/apiSyncStateMachine.test.js b/src/machines/apiSyncStateMachine.test.js new file mode 100644 index 0000000..4104290 --- /dev/null +++ b/src/machines/apiSyncStateMachine.test.js @@ -0,0 +1,732 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createActor, fromPromise } from "xstate"; + +import { apiSyncStateMachine } from "./apiSyncStateMachine"; + +describe("apiSyncStateMachine", () => { + // Mock formState helper + const createMockFormState = (target = { ynab: true, settleup: false }) => ({ + amountMilliunits: 25000, + description: "Test Transaction", + target, + account: { bourso: true, swile: false }, + payee: "Test Merchant", + payeeId: "test-payee-id", + category: "Groceries", + categoryId: "test-category-id", + date: new Date(), + settleUpCategory: "Food", + settleUpGroups: null, + settleUpGroup: null, + settleUpPayerId: "", + settleUpMembers: [], + settleUpCurrency: "EUR", + swileMilliunits: 25000, + showAccounts: false, + showDetails: false, + }); + + describe("Initial State", () => { + it("should start in idle state with correct initial context", () => { + const actor = createActor(apiSyncStateMachine); + actor.start(); + + expect(actor.getSnapshot().value).toBe("idle"); + expect(actor.getSnapshot().context).toEqual({ + formState: null, + results: { ynab: null, settleup: null }, + errors: { ynab: null, settleup: null }, + }); + + actor.stop(); + }); + }); + + describe("START_SYNC Transitions", () => { + it("should transition to syncing state and store formState", () => { + const actor = createActor(apiSyncStateMachine); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.matches("syncing")).toBe(true); + expect(snapshot.context.formState).toEqual(formState); + expect(snapshot.context.results).toEqual({ ynab: null, settleup: null }); + expect(snapshot.context.errors).toEqual({ ynab: null, settleup: null }); + + actor.stop(); + }); + + it("should handle YNAB-only target correctly", () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.matches({ syncing: { ynab: "submitting" } })).toBe(true); + expect(snapshot.matches({ syncing: { settleup: "skipped" } })).toBe(true); + + actor.stop(); + }); + + it("should handle SettleUp-only target correctly", () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: false, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.matches({ syncing: { ynab: "skipped" } })).toBe(true); + expect(snapshot.matches({ syncing: { settleup: "submitting" } })).toBe( + true, + ); + + actor.stop(); + }); + + it("should handle both APIs target correctly", () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.matches({ syncing: { ynab: "submitting" } })).toBe(true); + expect(snapshot.matches({ syncing: { settleup: "submitting" } })).toBe( + true, + ); + + actor.stop(); + }); + }); + + describe("Guards", () => { + let actor; + + beforeEach(() => { + actor = createActor(apiSyncStateMachine); + actor.start(); + }); + + afterEach(() => { + actor.stop(); + }); + + describe("ynabTargeted guard", () => { + it("should return true when YNAB is targeted", () => { + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.context.formState.target.ynab).toBe(true); + }); + + it("should return false when YNAB is not targeted", () => { + const formState = createMockFormState({ ynab: false, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.context.formState.target.ynab).toBe(false); + }); + + it("should handle null formState gracefully", () => { + const snapshot = actor.getSnapshot(); + expect(snapshot.context.formState?.target?.ynab || false).toBe(false); + }); + }); + + describe("settleupTargeted guard", () => { + it("should return true when SettleUp is targeted", () => { + const formState = createMockFormState({ ynab: false, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.context.formState.target.settleup).toBe(true); + }); + + it("should return false when SettleUp is not targeted", () => { + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.context.formState.target.settleup).toBe(false); + }); + }); + }); + + describe("Success Scenarios", () => { + it("should reach success state when all targeted APIs succeed", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + // Wait for the machine to settle + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("success") || + snapshot.matches("error") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); + + const finalSnapshot = actor.getSnapshot(); + expect(finalSnapshot.matches("success")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); + expect(finalSnapshot.context.results.settleup).toBe("SettleUp Success"); + expect(finalSnapshot.context.errors.ynab).toBe(null); + expect(finalSnapshot.context.errors.settleup).toBe(null); + + actor.stop(); + }); + + it("should reach success state when only YNAB is targeted and succeeds", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("success") || + snapshot.matches("error") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); + + const finalSnapshot = actor.getSnapshot(); + expect(finalSnapshot.matches("success")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); + expect(finalSnapshot.context.results.settleup).toBe(null); + + actor.stop(); + }); + }); + + describe("Error Scenarios", () => { + it("should reach error state when single targeted API fails", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("error") || + snapshot.matches("success") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); + + const finalSnapshot = actor.getSnapshot(); + expect(finalSnapshot.matches("error")).toBe(true); + expect(finalSnapshot.context.errors.ynab).toBeInstanceOf(Error); + expect(finalSnapshot.context.errors.ynab.message).toBe("YNAB Failed"); + + actor.stop(); + }); + + it("should reach partialSuccess state when one of multiple APIs fails", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("partialSuccess") || + snapshot.matches("error") || + snapshot.matches("success") + ); + }, + { timeout: 1000 }, + ); + + const finalSnapshot = actor.getSnapshot(); + expect(finalSnapshot.matches("partialSuccess")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); + expect(finalSnapshot.context.errors.settleup).toBeInstanceOf(Error); + + actor.stop(); + }); + + it("should reach partialSuccess when YNAB fails but SettleUp succeeds", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("partialSuccess") || + snapshot.matches("error") || + snapshot.matches("success") + ); + }, + { timeout: 1000 }, + ); + + const finalSnapshot = actor.getSnapshot(); + expect(finalSnapshot.matches("partialSuccess")).toBe(true); + expect(finalSnapshot.context.results.settleup).toBe("SettleUp Success"); + expect(finalSnapshot.context.results.ynab).toBe(null); + expect(finalSnapshot.context.errors.ynab).toBeInstanceOf(Error); + expect(finalSnapshot.context.errors.ynab.message).toBe("YNAB Failed"); + expect(finalSnapshot.context.errors.settleup).toBe(null); + + actor.stop(); + }); + + it("should reach partialSuccess when SettleUp fails but YNAB succeeds", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("partialSuccess") || + snapshot.matches("error") || + snapshot.matches("success") + ); + }, + { timeout: 1000 }, + ); + + const finalSnapshot = actor.getSnapshot(); + expect(finalSnapshot.matches("partialSuccess")).toBe(true); + expect(finalSnapshot.context.results.ynab).toBe("YNAB Success"); + expect(finalSnapshot.context.results.settleup).toBe(null); + expect(finalSnapshot.context.errors.ynab).toBe(null); + expect(finalSnapshot.context.errors.settleup).toBeInstanceOf(Error); + expect(finalSnapshot.context.errors.settleup.message).toBe( + "SettleUp Failed", + ); + + actor.stop(); + }); + + it("should reach error state when all targeted APIs fail", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return ( + snapshot.matches("error") || + snapshot.matches("success") || + snapshot.matches("partialSuccess") + ); + }, + { timeout: 1000 }, + ); + + const finalSnapshot = actor.getSnapshot(); + expect(finalSnapshot.matches("error")).toBe(true); + expect(finalSnapshot.context.errors.ynab).toBeInstanceOf(Error); + expect(finalSnapshot.context.errors.settleup).toBeInstanceOf(Error); + + actor.stop(); + }); + }); + + describe("Retry Logic", () => { + it("should retry all APIs when in error state", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + // Wait for error state + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("error"); + }, + { timeout: 1000 }, + ); + + // Test RETRY from error state + actor.send({ type: "RETRY" }); + const retrySnapshot = actor.getSnapshot(); + expect(retrySnapshot.matches("syncing")).toBe(true); + expect(retrySnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(retrySnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); + + actor.stop(); + }); + + it("should retry only failed APIs when in partialSuccess state", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + // Wait for partialSuccess + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("partialSuccess"); + }, + { timeout: 1000 }, + ); + + // Test RETRY_FAILED + actor.send({ type: "RETRY_FAILED" }); + const retrySnapshot = actor.getSnapshot(); + + expect(retrySnapshot.matches("syncing")).toBe(true); + // Should only target the failed API (settleup) - ynab was successful so errors.ynab should be null + expect(retrySnapshot.context.formState.target.ynab).toBe(false); // Was successful, so not retried + expect(retrySnapshot.context.formState.target.settleup).toBe(true); // Failed, so retried + + actor.stop(); + }); + + it("should retry only YNAB when it failed in partialSuccess state", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + // Wait for partialSuccess + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("partialSuccess"); + }, + { timeout: 1000 }, + ); + + // Test RETRY_FAILED + actor.send({ type: "RETRY_FAILED" }); + const retrySnapshot = actor.getSnapshot(); + + expect(retrySnapshot.matches("syncing")).toBe(true); + // Should only target the failed API (ynab) - settleup was successful + expect(retrySnapshot.context.formState.target.ynab).toBe(true); // Failed, so retried + expect(retrySnapshot.context.formState.target.settleup).toBe(false); // Was successful, so not retried + + actor.stop(); + }); + }); + + describe("Reset Functionality", () => { + it("should reset to idle from success state", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("success"); + }, + { timeout: 1000 }, + ); + + actor.send({ type: "RESET" }); + const resetSnapshot = actor.getSnapshot(); + + expect(resetSnapshot.matches("idle")).toBe(true); + expect(resetSnapshot.context.formState).toBe(null); + expect(resetSnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(resetSnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); + + actor.stop(); + }); + + it("should reset to idle from error state", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => + Promise.reject(new Error("YNAB Failed")), + ), + submitSettleup: fromPromise(() => + Promise.resolve("SettleUp Success"), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + // Wait for error state + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("error"); + }, + { timeout: 1000 }, + ); + + actor.send({ type: "RESET" }); + const resetSnapshot = actor.getSnapshot(); + + expect(resetSnapshot.matches("idle")).toBe(true); + expect(resetSnapshot.context.formState).toBe(null); + expect(resetSnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(resetSnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); + + actor.stop(); + }); + + it("should reset to idle from partialSuccess state", async () => { + const machineWithMockServices = apiSyncStateMachine.provide({ + actors: { + submitYnab: fromPromise(() => Promise.resolve("YNAB Success")), + submitSettleup: fromPromise(() => + Promise.reject(new Error("SettleUp Failed")), + ), + }, + }); + + const actor = createActor(machineWithMockServices); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: true }); + actor.send({ type: "START_SYNC", formState }); + + // Wait for partialSuccess + await vi.waitFor( + () => { + const snapshot = actor.getSnapshot(); + return snapshot.matches("partialSuccess"); + }, + { timeout: 1000 }, + ); + + actor.send({ type: "RESET" }); + const resetSnapshot = actor.getSnapshot(); + + expect(resetSnapshot.matches("idle")).toBe(true); + expect(resetSnapshot.context.formState).toBe(null); + expect(resetSnapshot.context.results).toEqual({ + ynab: null, + settleup: null, + }); + expect(resetSnapshot.context.errors).toEqual({ + ynab: null, + settleup: null, + }); + + actor.stop(); + }); + }); + + describe("Context Management", () => { + it("should store formState correctly when START_SYNC is sent", () => { + const actor = createActor(apiSyncStateMachine); + actor.start(); + + const formState = createMockFormState({ ynab: true, settleup: false }); + actor.send({ type: "START_SYNC", formState }); + + const snapshot = actor.getSnapshot(); + expect(snapshot.context.formState.target.ynab).toBe(true); + expect(snapshot.context.formState.target.settleup).toBe(false); + expect(snapshot.context.formState.description).toBe("Test Transaction"); + expect(snapshot.context.formState.amountMilliunits).toBe(25000); + expect(snapshot.context.results).toEqual({ ynab: null, settleup: null }); + expect(snapshot.context.errors).toEqual({ ynab: null, settleup: null }); + + actor.stop(); + }); + }); +}); diff --git a/src/main.jsx b/src/main.jsx index 6a00acf..f679e93 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -20,6 +20,7 @@ import { import LoginPage from "./LoginPage.jsx"; import MainFormPage from "./MainFormPage.jsx"; import NotFoundPage from "./NotFoundPage.jsx"; +import StateMachineTest from "./StateMachineTest.jsx"; import "./index.css"; function RequireAuth({ children }) { @@ -81,6 +82,7 @@ function RouterApp() { } /> } /> + } /> {