diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index 3668420..73d260c 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -1,6 +1,11 @@ require('@testing-library/jest-dom') const React = require('react') +// jsdom doesn't expose structuredClone, which fake-indexeddb relies on. +if (typeof global.structuredClone !== 'function') { + global.structuredClone = (value) => JSON.parse(JSON.stringify(value)) +} + if (typeof global.fetch !== 'function') { global.fetch = jest.fn(() => Promise.resolve({ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2e4b937..086ceaa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "babel-plugin-react-compiler": "1.0.0", "eslint": "^10", "eslint-config-next": "16.2.7", + "fake-indexeddb": "^6.2.5", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "prettier": "^3.8.3", @@ -14115,6 +14116,16 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 6df954b..c8ed00c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "babel-plugin-react-compiler": "1.0.0", "eslint": "^10", "eslint-config-next": "16.2.7", + "fake-indexeddb": "^6.2.5", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "prettier": "^3.8.3", diff --git a/frontend/src/lib/offline/__tests__/taskConfigDb.test.ts b/frontend/src/lib/offline/__tests__/taskConfigDb.test.ts new file mode 100644 index 0000000..52d33bc --- /dev/null +++ b/frontend/src/lib/offline/__tests__/taskConfigDb.test.ts @@ -0,0 +1,97 @@ +import "fake-indexeddb/auto"; +import { + saveTaskConfig, + getTaskConfig, + getAllTaskConfigs, + getPendingConfigs, + markConfigSynced, + deleteTaskConfig, + syncTaskConfigs, + type TaskConfig, +} from "../taskConfigDb"; + +function makeConfig(id: string): TaskConfig { + return { + id, + contractAddress: "CABCDEF1234", + functionName: "harvest_yield", + interval: 60, + gasBalance: 100, + updatedAt: 1, + }; +} + +function resetDb(): Promise { + return new Promise((resolve) => { + const req = indexedDB.deleteDatabase("sorotask-offline"); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +} + +beforeEach(resetDb); + +describe("taskConfigDb", () => { + it("saves a config as pending and reads it back", async () => { + await saveTaskConfig(makeConfig("a")); + const got = await getTaskConfig("a"); + expect(got?.syncState).toBe("pending"); + expect(got?.functionName).toBe("harvest_yield"); + }); + + it("returns undefined for an unknown id", async () => { + expect(await getTaskConfig("missing")).toBeUndefined(); + }); + + it("lists all configs and filters pending ones", async () => { + await saveTaskConfig(makeConfig("a")); + await saveTaskConfig(makeConfig("b")); + await markConfigSynced("a"); + + expect(await getAllTaskConfigs()).toHaveLength(2); + const pending = await getPendingConfigs(); + expect(pending.map((c) => c.id)).toEqual(["b"]); + }); + + it("deletes a config", async () => { + await saveTaskConfig(makeConfig("a")); + await deleteTaskConfig("a"); + expect(await getTaskConfig("a")).toBeUndefined(); + }); + + it("syncs all pending configs and marks them synced", async () => { + await saveTaskConfig(makeConfig("a")); + await saveTaskConfig(makeConfig("b")); + + const push = jest.fn().mockResolvedValue(undefined); + const result = await syncTaskConfigs(push); + + expect(result).toEqual({ synced: 2, failed: 0 }); + expect(push).toHaveBeenCalledTimes(2); + expect(await getPendingConfigs()).toHaveLength(0); + }); + + it("keeps a config pending when its push fails", async () => { + await saveTaskConfig(makeConfig("a")); + await saveTaskConfig(makeConfig("b")); + + const push = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("rpc down")); + const result = await syncTaskConfigs(push); + + expect(result).toEqual({ synced: 1, failed: 1 }); + expect(await getPendingConfigs()).toHaveLength(1); + }); + + it("passes a config without sync metadata to push", async () => { + await saveTaskConfig(makeConfig("a")); + const push = jest.fn().mockResolvedValue(undefined); + await syncTaskConfigs(push); + expect(push).toHaveBeenCalledWith( + expect.not.objectContaining({ syncState: expect.anything() }), + ); + }); +}); diff --git a/frontend/src/lib/offline/__tests__/useOfflineTaskConfig.test.tsx b/frontend/src/lib/offline/__tests__/useOfflineTaskConfig.test.tsx new file mode 100644 index 0000000..2966e6d --- /dev/null +++ b/frontend/src/lib/offline/__tests__/useOfflineTaskConfig.test.tsx @@ -0,0 +1,68 @@ +import "fake-indexeddb/auto"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useOfflineTaskConfig } from "../useOfflineTaskConfig"; +import { type TaskConfig } from "../taskConfigDb"; + +function makeConfig(id: string): TaskConfig { + return { + id, + contractAddress: "CABCDEF1234", + functionName: "harvest_yield", + interval: 60, + gasBalance: 100, + updatedAt: 1, + }; +} + +function setOnline(value: boolean) { + Object.defineProperty(navigator, "onLine", { configurable: true, value }); +} + +function resetDb(): Promise { + return new Promise((resolve) => { + const req = indexedDB.deleteDatabase("sorotask-offline"); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +} + +beforeEach(async () => { + await resetDb(); + setOnline(true); +}); + +describe("useOfflineTaskConfig", () => { + it("stores configs offline and syncs them when back online", async () => { + setOnline(false); + const push = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useOfflineTaskConfig(push)); + + await act(async () => { + await result.current.saveConfig(makeConfig("a")); + }); + + expect(result.current.pendingCount).toBe(1); + expect(push).not.toHaveBeenCalled(); + + await act(async () => { + setOnline(true); + window.dispatchEvent(new Event("online")); + }); + + await waitFor(() => expect(push).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.pendingCount).toBe(0)); + }); + + it("syncs immediately when saving while online", async () => { + const push = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useOfflineTaskConfig(push)); + + await act(async () => { + await result.current.saveConfig(makeConfig("b")); + }); + + await waitFor(() => expect(push).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.pendingCount).toBe(0)); + }); +}); diff --git a/frontend/src/lib/offline/taskConfigDb.ts b/frontend/src/lib/offline/taskConfigDb.ts new file mode 100644 index 0000000..5df96d3 --- /dev/null +++ b/frontend/src/lib/offline/taskConfigDb.ts @@ -0,0 +1,127 @@ +const DB_NAME = "sorotask-offline"; +const DB_VERSION = 1; +const STORE = "task-configs"; + +export interface TaskConfig { + id: string; + contractAddress: string; + functionName: string; + interval: number; + gasBalance: number; + updatedAt: number; +} + +export type SyncState = "pending" | "synced"; + +export interface StoredTaskConfig extends TaskConfig { + syncState: SyncState; +} + +export interface SyncResult { + synced: number; + failed: number; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE)) { + const store = db.createObjectStore(STORE, { keyPath: "id" }); + store.createIndex("syncState", "syncState", { unique: false }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function run( + mode: IDBTransactionMode, + op: (store: IDBObjectStore) => IDBRequest, +): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, mode); + const request = op(tx.objectStore(STORE)); + let result: T; + request.onsuccess = () => { + result = request.result as T; + }; + request.onerror = () => reject(request.error); + tx.oncomplete = () => resolve(result); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +export async function saveTaskConfig(config: TaskConfig): Promise { + const record: StoredTaskConfig = { ...config, syncState: "pending" }; + await run("readwrite", (store) => store.put(record)); + return record; +} + +export function getTaskConfig(id: string): Promise { + return run("readonly", (store) => store.get(id)); +} + +export async function getAllTaskConfigs(): Promise { + return (await run("readonly", (store) => store.getAll())) ?? []; +} + +export async function getPendingConfigs(): Promise { + return ( + (await run("readonly", (store) => + store.index("syncState").getAll("pending"), + )) ?? [] + ); +} + +export async function markConfigSynced(id: string): Promise { + const existing = await getTaskConfig(id); + if (!existing) return; + await run("readwrite", (store) => store.put({ ...existing, syncState: "synced" })); +} + +export async function deleteTaskConfig(id: string): Promise { + await run("readwrite", (store) => store.delete(id)); +} + +function toConfig(stored: StoredTaskConfig): TaskConfig { + return { + id: stored.id, + contractAddress: stored.contractAddress, + functionName: stored.functionName, + interval: stored.interval, + gasBalance: stored.gasBalance, + updatedAt: stored.updatedAt, + }; +} + +// Flush every pending config through `push`. Each item is independent: a +// failure leaves that config pending for the next attempt and does not abort +// the rest of the batch. +export async function syncTaskConfigs( + push: (config: TaskConfig) => Promise, +): Promise { + const pending = await getPendingConfigs(); + let synced = 0; + let failed = 0; + + for (const config of pending) { + try { + await push(toConfig(config)); + await markConfigSynced(config.id); + synced++; + } catch { + failed++; + } + } + + return { synced, failed }; +} diff --git a/frontend/src/lib/offline/useOfflineTaskConfig.ts b/frontend/src/lib/offline/useOfflineTaskConfig.ts new file mode 100644 index 0000000..24c87e7 --- /dev/null +++ b/frontend/src/lib/offline/useOfflineTaskConfig.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useOnlineStatus } from "../network/useOnlineStatus"; +import { + getAllTaskConfigs, + saveTaskConfig, + syncTaskConfigs, + type StoredTaskConfig, + type TaskConfig, +} from "./taskConfigDb"; + +export interface UseOfflineTaskConfig { + configs: StoredTaskConfig[]; + pendingCount: number; + online: boolean; + syncing: boolean; + saveConfig: (config: TaskConfig) => Promise; + sync: () => Promise; +} + +// Persists task configs to IndexedDB so they survive offline, and flushes them +// through `push` whenever connectivity is (re)established. +export function useOfflineTaskConfig( + push: (config: TaskConfig) => Promise, +): UseOfflineTaskConfig { + const { online } = useOnlineStatus(); + const [configs, setConfigs] = useState([]); + const [syncing, setSyncing] = useState(false); + + const pushRef = useRef(push); + pushRef.current = push; + + const refresh = useCallback(async () => { + setConfigs(await getAllTaskConfigs()); + }, []); + + const sync = useCallback(async () => { + setSyncing(true); + try { + await syncTaskConfigs((config) => pushRef.current(config)); + await refresh(); + } finally { + setSyncing(false); + } + }, [refresh]); + + const saveConfig = useCallback( + async (config: TaskConfig) => { + await saveTaskConfig(config); + await refresh(); + if (online) await sync(); + }, + [refresh, sync, online], + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (online) void sync(); + }, [online, sync]); + + const pendingCount = configs.filter((c) => c.syncState === "pending").length; + + return { configs, pendingCount, online, syncing, saveConfig, sync }; +}