Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/native/app/__tests__/_layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ jest.mock("@/utils/trpc", () => ({
jest.mock("@/lib/local-db-provider", () => ({
LocalDbProvider: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock("@/lib/sync-provider", () => ({
SyncProvider: ({ children }: { children: React.ReactNode }) => children,
}));

import Layout from "../_layout";

Expand Down
29 changes: 16 additions & 13 deletions apps/native/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AppThemeProvider } from "@/contexts/app-theme-context";
import { WorkoutProvider } from "@/contexts/workout-context";
import { authClient } from "@/lib/auth-client";
import { LocalDbProvider } from "@/lib/local-db-provider";
import { SyncProvider } from "@/lib/sync-provider";
import { queryClient } from "@/utils/trpc";

SplashScreen.preventAutoHideAsync();
Expand Down Expand Up @@ -80,19 +81,21 @@ export default function Layout() {
return (
<QueryClientProvider client={queryClient}>
<LocalDbProvider>
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppThemeProvider>
<WorkoutProvider>
<HeroUINativeProvider>
<StackLayout />
</HeroUINativeProvider>
</WorkoutProvider>
</AppThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
<SyncProvider>
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppThemeProvider>
<WorkoutProvider>
<HeroUINativeProvider>
<StackLayout />
</HeroUINativeProvider>
</WorkoutProvider>
</AppThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
</SyncProvider>
</LocalDbProvider>
</QueryClientProvider>
);
Expand Down
67 changes: 67 additions & 0 deletions apps/native/lib/__tests__/sync-auth-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as schema from "@ironlog/db/schema";
import { createTestDb } from "../test-db-helper";
import { runSyncWithRetry } from "../sync-engine";
import type { SyncClient } from "../sync-engine";

function seedQueue(db: any) {
db.insert(schema.syncQueue)
.values({
id: "sq-1",
tableName: "workouts",
recordId: "w-1",
operation: "insert",
payload: JSON.stringify({ id: "w-1", userId: "u-1", title: "Push Day" }),
createdAt: Date.now(),
})
.run();
}

describe("runSyncWithRetry", () => {
it("retries once after UNAUTHORIZED, then succeeds", async () => {
const { db } = createTestDb();
seedQueue(db);

const mockMutate = jest
.fn()
.mockRejectedValueOnce({ code: "UNAUTHORIZED" })
.mockResolvedValueOnce({ success: true });

const mockQuery = jest.fn().mockResolvedValue({ changes: [], cursor: 0 });

const client: SyncClient = {
sync: {
push: { mutate: mockMutate },
pull: { query: mockQuery },
},
};

const refreshSession = jest.fn().mockResolvedValue(undefined);

await runSyncWithRetry(db, client, refreshSession);

expect(refreshSession).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledTimes(2);
});

it("throws after both attempts fail with UNAUTHORIZED", async () => {
const { db } = createTestDb();
seedQueue(db);

const unauthorizedError = { code: "UNAUTHORIZED" };
const mockMutate = jest.fn().mockRejectedValue(unauthorizedError);
const mockQuery = jest.fn().mockResolvedValue({ changes: [], cursor: 0 });

const client: SyncClient = {
sync: {
push: { mutate: mockMutate },
pull: { query: mockQuery },
},
};

const refreshSession = jest.fn().mockResolvedValue(undefined);

await expect(runSyncWithRetry(db, client, refreshSession)).rejects.toEqual(unauthorizedError);
expect(refreshSession).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledTimes(2);
});
});
50 changes: 50 additions & 0 deletions apps/native/lib/__tests__/sync-debounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createSyncScheduler } from "../sync-engine";

describe("createSyncScheduler", () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it("fires syncFn once after 1s debounce when notifyWrite called twice", () => {
const syncFn = jest.fn().mockResolvedValue(undefined);
const { notifyWrite, cleanup } = createSyncScheduler(syncFn);

notifyWrite();
notifyWrite();

expect(syncFn).not.toHaveBeenCalled();

jest.advanceTimersByTime(1000);

expect(syncFn).toHaveBeenCalledTimes(1);

cleanup();
});

it("triggerNow fires immediately", () => {
const syncFn = jest.fn().mockResolvedValue(undefined);
const { triggerNow, cleanup } = createSyncScheduler(syncFn);

triggerNow();

expect(syncFn).toHaveBeenCalledTimes(1);

cleanup();
});

it("cleanup cancels pending debounce", () => {
const syncFn = jest.fn().mockResolvedValue(undefined);
const { notifyWrite, cleanup } = createSyncScheduler(syncFn);

notifyWrite();
cleanup();

jest.advanceTimersByTime(2000);

expect(syncFn).not.toHaveBeenCalled();
});
});
102 changes: 102 additions & 0 deletions apps/native/lib/__tests__/sync-lww.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as schema from "@ironlog/db/schema";
import { createTestDb } from "../test-db-helper";
import { pullChanges, setLastSyncCursor } from "../sync-engine";
import type { SyncClient } from "../sync-engine";

describe("LWW on pull", () => {
function setup() {
const { db } = createTestDb();
db.insert(schema.user)
.values({ id: "u-1", name: "Test", email: "t@t.com", updatedAt: new Date() })
.run();
return db;
}

it("preserves local row when local updatedAt is newer than server", () => {
const db = setup();

// Insert local workout with updatedAt=9000
db.insert(schema.workouts)
.values({
id: "w-1",
userId: "u-1",
title: "Local Title",
createdAt: new Date(1000),
updatedAt: new Date(9000),
})
.run();

const client: SyncClient = {
sync: {
push: { mutate: jest.fn() },
pull: {
query: jest.fn().mockResolvedValue({
changes: [
{
table: "workouts",
id: "w-1",
userId: "u-1",
title: "Server Title",
createdAt: 1000,
updatedAt: 5000,
deletedAt: null,
},
],
cursor: 5000,
}),
},
},
};

setLastSyncCursor(db, 0);

return pullChanges(db, client).then(() => {
const rows = db.select().from(schema.workouts).all();
expect(rows[0].title).toBe("Local Title");
});
});

it("applies server row when server updatedAt is newer than local", () => {
const db = setup();

// Insert local workout with updatedAt=3000
db.insert(schema.workouts)
.values({
id: "w-1",
userId: "u-1",
title: "Local Title",
createdAt: new Date(1000),
updatedAt: new Date(3000),
})
.run();

const client: SyncClient = {
sync: {
push: { mutate: jest.fn() },
pull: {
query: jest.fn().mockResolvedValue({
changes: [
{
table: "workouts",
id: "w-1",
userId: "u-1",
title: "Server Title",
createdAt: 1000,
updatedAt: 9000,
deletedAt: null,
},
],
cursor: 9000,
}),
},
},
};

setLastSyncCursor(db, 0);

return pullChanges(db, client).then(() => {
const rows = db.select().from(schema.workouts).all();
expect(rows[0].title).toBe("Server Title");
});
});
});
22 changes: 22 additions & 0 deletions apps/native/lib/__tests__/sync-meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createTestDb } from "../test-db-helper";
import { getLastSyncCursor, setLastSyncCursor } from "../sync-engine";

describe("_sync_meta cursor", () => {
it("returns 0 when no cursor is stored", () => {
const { db } = createTestDb();
expect(getLastSyncCursor(db)).toBe(0);
});

it("stores and retrieves a cursor", () => {
const { db } = createTestDb();
setLastSyncCursor(db, 1234567890);
expect(getLastSyncCursor(db)).toBe(1234567890);
});

it("overwrites existing cursor on re-set", () => {
const { db } = createTestDb();
setLastSyncCursor(db, 1000);
setLastSyncCursor(db, 2000);
expect(getLastSyncCursor(db)).toBe(2000);
});
});
94 changes: 94 additions & 0 deletions apps/native/lib/__tests__/sync-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
jest.mock("expo-sqlite", () => ({
openDatabaseSync: jest.fn(() => "mock-sqlite-db"),
}));

jest.mock("@ironlog/db/client", () => ({
createLocalDb: jest.fn(() => ({
select: jest.fn(),
insert: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
})),
}));

jest.mock("drizzle-orm/expo-sqlite/migrator", () => ({
useMigrations: jest.fn(() => ({ success: true, error: undefined })),
}));

jest.mock("@ironlog/db/local-migrations/migrations", () => ({
default: { journal: { entries: [] }, migrations: {} },
}));

jest.mock("expo-network", () => ({
getNetworkStateAsync: jest.fn().mockResolvedValue({ isConnected: true }),
}));

jest.mock("@react-native-community/netinfo", () => ({
addEventListener: jest.fn(() => jest.fn()),
}));

jest.mock("../local-db-provider", () => {
const mockDb = {
select: jest.fn(() => ({ from: jest.fn(() => ({ all: jest.fn(() => []) })) })),
insert: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
};
return {
useLocalDb: jest.fn(() => mockDb),
LocalDbProvider: ({ children }: { children: React.ReactNode }) => children,
};
});

jest.mock("@/utils/trpc", () => ({
trpc: {},
queryClient: {},
}));

jest.mock("@/lib/auth-client", () => ({
authClient: {
getSession: jest.fn().mockResolvedValue({}),
},
}));

import React from "react";
import { renderHook, waitFor } from "@testing-library/react-native";
import { SyncProvider, useSyncStatus } from "../sync-provider";
import * as Network from "expo-network";

describe("SyncProvider", () => {
beforeEach(() => {
jest.clearAllMocks();
(Network.getNetworkStateAsync as jest.Mock).mockResolvedValue({ isConnected: true });
});

function wrapper({ children }: { children: React.ReactNode }) {
return <SyncProvider>{children}</SyncProvider>;
}

it("provides synced status after successful init", async () => {
const { result } = renderHook(() => useSyncStatus(), { wrapper });

await waitFor(() => {
expect(["synced", "syncing"]).toContain(result.current.status);
});
});

it("provides offline status when network unavailable", async () => {
(Network.getNetworkStateAsync as jest.Mock).mockResolvedValue({ isConnected: false });

const { result } = renderHook(() => useSyncStatus(), { wrapper });

await waitFor(() => {
expect(result.current.status).toBe("offline");
});
});

it("exposes notifyWrite as a function", async () => {
const { result } = renderHook(() => useSyncStatus(), { wrapper });

await waitFor(() => {
expect(typeof result.current.notifyWrite).toBe("function");
});
});
});
Loading
Loading