From 5e3d4dc34ae1c9dfb9ad7dd9aa4903a96053fe65 Mon Sep 17 00:00:00 2001
From: Caleb <119738245+Calebux@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:54:24 +0100
Subject: [PATCH] test(home): add AsyncStorage feed cache tests for issue #895
Cover the two-phase load (cache-first then network) introduced in
home.tsx:
- Cached items are shown immediately on mount before network responds
- Fresh network data replaces cached items after a successful fetch
- Empty cache gracefully falls back to INITIAL_FEED
- AsyncStorage read errors are swallowed; INITIAL_FEED shown instead
- Fetched items are written back to AsyncStorage after success
- Network fetch errors leave existing cached items visible on screen
- Empty network response does not overwrite a populated cache
---
mobileapp/__tests__/homeCache.test.tsx | 278 +++++++++++++++++++++++++
1 file changed, 278 insertions(+)
create mode 100644 mobileapp/__tests__/homeCache.test.tsx
diff --git a/mobileapp/__tests__/homeCache.test.tsx b/mobileapp/__tests__/homeCache.test.tsx
new file mode 100644
index 0000000..058434e
--- /dev/null
+++ b/mobileapp/__tests__/homeCache.test.tsx
@@ -0,0 +1,278 @@
+/**
+ * Tests for issue #895 – AsyncStorage-backed feed cache in HomeScreen.
+ *
+ * Strategy: mount HomeScreen with all heavy dependencies mocked so the
+ * only observable behaviour is the two-phase load (cache → network) and
+ * the write-back after a successful fetch.
+ */
+
+// AsyncStorage mock must be registered before any module imports.
+jest.mock("@react-native-async-storage/async-storage", () =>
+ require("@react-native-async-storage/async-storage/jest/async-storage-mock")
+);
+
+import React from "react";
+import { render, waitFor, act } from "@testing-library/react-native";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+
+// ── Heavy native / third-party mocks ─────────────────────────────────────────
+
+jest.mock("expo-router", () => ({
+ useRouter: () => ({ push: jest.fn(), back: jest.fn(), replace: jest.fn() }),
+}));
+
+jest.mock("expo-haptics", () => ({
+ selectionAsync: jest.fn(() => Promise.resolve()),
+ impactAsync: jest.fn(() => Promise.resolve()),
+ ImpactFeedbackStyle: { Light: "light", Medium: "medium", Heavy: "heavy" },
+}));
+
+jest.mock("expo-notifications", () => ({
+ PermissionStatus: { GRANTED: "granted", DENIED: "denied" },
+}));
+
+jest.mock("react-native-safe-area-context", () => ({
+ SafeAreaView: ({ children }: { children: React.ReactNode }) => children,
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
+}));
+
+jest.mock("react-native-svg", () => {
+ const React = require("react");
+ const mock = ({ children }: { children?: React.ReactNode }) =>
+ React.createElement("View", null, children);
+ return {
+ __esModule: true,
+ default: mock,
+ Svg: mock,
+ Defs: mock,
+ LinearGradient: mock,
+ Stop: mock,
+ Path: mock,
+ Circle: mock,
+ };
+});
+
+jest.mock("@expo/vector-icons", () => ({
+ Ionicons: () => null,
+}));
+
+// ── Service mocks ─────────────────────────────────────────────────────────────
+
+const mockFetchFeed = jest.fn();
+const mockLikePayment = jest.fn();
+const mockUnlikePayment = jest.fn();
+const mockAddComment = jest.fn();
+
+jest.mock("../../src/services/socialService", () => ({
+ fetchFeed: (...args: unknown[]) => mockFetchFeed(...args),
+ likePayment: (...args: unknown[]) => mockLikePayment(...args),
+ unlikePayment: (...args: unknown[]) => mockUnlikePayment(...args),
+ addComment: (...args: unknown[]) => mockAddComment(...args),
+}));
+
+const mockFetchYieldBalance = jest.fn();
+const mockUpdateAutoEarn = jest.fn();
+
+jest.mock("../../src/services/api", () => ({
+ fetchYieldBalance: (...args: unknown[]) => mockFetchYieldBalance(...args),
+ updateAutoEarn: (...args: unknown[]) => mockUpdateAutoEarn(...args),
+}));
+
+jest.mock("../../src/services/notificationService", () => ({
+ shouldShowNotificationConsentPrompt: jest.fn(() => Promise.resolve(false)),
+ markNotificationPromptDismissed: jest.fn(() => Promise.resolve()),
+ registerForPushNotificationsAsync: jest.fn(() => Promise.resolve()),
+ requestNotificationPermissionsAsync: jest.fn(() => Promise.resolve("denied")),
+ saveNotificationPreference: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock("../../src/utils/balanceDisplay", () => ({
+ formatBalance: (_val: number, _cur: string) => "₦0.00",
+ formatFiatBalance: (_val: number) => "₦0.00",
+ fiatFromTokens: (_val: number) => 0,
+}));
+
+jest.mock("../../src/constants/colors", () => ({
+ COLORS: { primary: "#000", secondary: "#fff" },
+}));
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const FEED_CACHE_KEY = "feed_items_cache";
+
+const makeFeedItem = (id: string) => ({
+ id,
+ sender: `Sender${id}`,
+ receiver: `Receiver${id}`,
+ amount: "₦1,000",
+ description: `Payment ${id}`,
+ timestamp: "1h ago",
+ likes: 0,
+ comments: 0,
+ hasLiked: false,
+ visibility: "PUBLIC" as const,
+});
+
+/** A default yield response that won't throw. */
+const defaultYield = {
+ apy: "5%",
+ availableBalance: 0,
+ totalYieldEarned: 0,
+ explanation: "Test",
+ autoEarnEnabled: true,
+};
+
+// ── Import subject under test (after all mocks are set up) ───────────────────
+
+// eslint-disable-next-line import/first
+import HomeScreen from "../app/(personal)/home";
+
+// ── Test suite ────────────────────────────────────────────────────────────────
+
+describe("HomeScreen – AsyncStorage feed cache (#895)", () => {
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ await AsyncStorage.clear();
+
+ // Default: yield call resolves quickly so it doesn't affect feed tests.
+ mockFetchYieldBalance.mockResolvedValue(defaultYield);
+ // Default: network fetch returns empty so cached data stays visible.
+ mockFetchFeed.mockResolvedValue([]);
+ });
+
+ // ── 1. Load from cache on mount ───────────────────────────────────────────
+
+ it("displays cached feed items immediately on mount before network responds", async () => {
+ const cached = [makeFeedItem("cached-1"), makeFeedItem("cached-2")];
+ await AsyncStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cached));
+
+ // Network fetch never resolves during this test so only cache is visible.
+ mockFetchFeed.mockReturnValue(new Promise(() => {}));
+
+ const { getByText } = render();
+
+ await waitFor(() => {
+ expect(getByText("Payment cached-1")).toBeTruthy();
+ expect(getByText("Payment cached-2")).toBeTruthy();
+ });
+ });
+
+ // ── 2. Fetch updates overwrite the cached data ────────────────────────────
+
+ it("replaces cached items with fresh data after network fetch succeeds", async () => {
+ const cached = [makeFeedItem("old-1")];
+ const fresh = [makeFeedItem("new-1"), makeFeedItem("new-2")];
+
+ await AsyncStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cached));
+ mockFetchFeed.mockResolvedValue(fresh);
+
+ const { getByText, queryByText } = render();
+
+ await waitFor(() => {
+ expect(getByText("Payment new-1")).toBeTruthy();
+ expect(getByText("Payment new-2")).toBeTruthy();
+ });
+
+ // Old cached item should no longer be rendered.
+ expect(queryByText("Payment old-1")).toBeNull();
+ });
+
+ // ── 3. Empty cache – falls back to INITIAL_FEED ───────────────────────────
+
+ it("renders without crashing when AsyncStorage has no cached feed", async () => {
+ // Cache is empty (cleared in beforeEach); network returns nothing.
+ mockFetchFeed.mockResolvedValue([]);
+
+ const { queryByText } = render();
+
+ // The component should still mount and the initial static feed is shown.
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 100));
+ });
+
+ // No crash: the component tree should be present.
+ // INITIAL_FEED has item with description "Lunch 🍕".
+ await waitFor(() => {
+ expect(queryByText("Lunch 🍕")).toBeTruthy();
+ });
+ });
+
+ // ── 4. AsyncStorage read error is silently swallowed ─────────────────────
+
+ it("falls back to INITIAL_FEED when AsyncStorage.getItem throws", async () => {
+ const getItemSpy = jest
+ .spyOn(AsyncStorage, "getItem")
+ .mockRejectedValueOnce(new Error("Storage quota exceeded"));
+
+ mockFetchFeed.mockResolvedValue([]);
+
+ const { queryByText } = render();
+
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 150));
+ });
+
+ // Component still renders the initial feed instead of crashing.
+ await waitFor(() => {
+ expect(queryByText("Lunch 🍕")).toBeTruthy();
+ });
+
+ getItemSpy.mockRestore();
+ });
+
+ // ── 5. Fresh data is written back to AsyncStorage after successful fetch ──
+
+ it("writes fresh feed items to AsyncStorage after a successful network fetch", async () => {
+ const fresh = [makeFeedItem("fresh-1"), makeFeedItem("fresh-2")];
+ mockFetchFeed.mockResolvedValue(fresh);
+
+ render();
+
+ await waitFor(async () => {
+ const stored = await AsyncStorage.getItem(FEED_CACHE_KEY);
+ expect(stored).not.toBeNull();
+ const parsed = JSON.parse(stored!);
+ expect(parsed).toHaveLength(2);
+ expect(parsed[0].id).toBe("fresh-1");
+ expect(parsed[1].id).toBe("fresh-2");
+ });
+ });
+
+ // ── 6. Network error leaves cache intact on screen ────────────────────────
+
+ it("keeps cached items visible when the network fetch throws", async () => {
+ const cached = [makeFeedItem("cached-safe")];
+ await AsyncStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cached));
+
+ mockFetchFeed.mockRejectedValue(new Error("Network error"));
+
+ const { getByText } = render();
+
+ await waitFor(() => {
+ expect(getByText("Payment cached-safe")).toBeTruthy();
+ });
+
+ // Cache should remain unchanged after the failed fetch.
+ const stored = await AsyncStorage.getItem(FEED_CACHE_KEY);
+ expect(JSON.parse(stored!)[0].id).toBe("cached-safe");
+ });
+
+ // ── 7. Empty network response does not overwrite valid cache ──────────────
+
+ it("does not overwrite AsyncStorage when the network returns an empty array", async () => {
+ const cached = [makeFeedItem("keep-me")];
+ await AsyncStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cached));
+
+ // Network returns empty – should not clear the stored cache.
+ mockFetchFeed.mockResolvedValue([]);
+
+ render();
+
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 150));
+ });
+
+ const stored = await AsyncStorage.getItem(FEED_CACHE_KEY);
+ expect(JSON.parse(stored!)[0].id).toBe("keep-me");
+ });
+});