Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/jest.setup.js
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
11 changes: 11 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions frontend/src/lib/offline/__tests__/taskConfigDb.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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() }),
);
});
});
68 changes: 68 additions & 0 deletions frontend/src/lib/offline/__tests__/useOfflineTaskConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
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));
});
});
127 changes: 127 additions & 0 deletions frontend/src/lib/offline/taskConfigDb.ts
Original file line number Diff line number Diff line change
@@ -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<IDBDatabase> {
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<T>(
mode: IDBTransactionMode,
op: (store: IDBObjectStore) => IDBRequest,
): Promise<T> {
const db = await openDb();
try {
return await new Promise<T>((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<StoredTaskConfig> {
const record: StoredTaskConfig = { ...config, syncState: "pending" };
await run("readwrite", (store) => store.put(record));
return record;
}

export function getTaskConfig(id: string): Promise<StoredTaskConfig | undefined> {
return run("readonly", (store) => store.get(id));
}

export async function getAllTaskConfigs(): Promise<StoredTaskConfig[]> {
return (await run<StoredTaskConfig[]>("readonly", (store) => store.getAll())) ?? [];
}

export async function getPendingConfigs(): Promise<StoredTaskConfig[]> {
return (
(await run<StoredTaskConfig[]>("readonly", (store) =>
store.index("syncState").getAll("pending"),
)) ?? []
);
}

export async function markConfigSynced(id: string): Promise<void> {
const existing = await getTaskConfig(id);
if (!existing) return;
await run("readwrite", (store) => store.put({ ...existing, syncState: "synced" }));
}

export async function deleteTaskConfig(id: string): Promise<void> {
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<void>,
): Promise<SyncResult> {
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 };
}
Loading