diff --git a/.eslintrc.js b/.eslintrc.js index c4a026f..72c32d3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -38,6 +38,17 @@ module.exports = { "@typescript-eslint/no-require-imports": "off", }, }, + { + // Jest manual mocks are CommonJS modules (module.exports / require) that + // run in the Node test environment — give them the right globals so the + // `no-undef` / require rules don't flag legitimate mock plumbing. + files: ["__mocks__/**"], + env: { node: true, jest: true }, + rules: { + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-var-requires": "off", + }, + }, ], ignorePatterns: ["node_modules/", ".expo/", "dist/", "*.config.js", "babel.config.js", "server/hotcrm/"], }; diff --git a/.github/workflows/eas-build.yml b/.github/workflows/eas-build.yml index e681d18..f59586d 100644 --- a/.github/workflows/eas-build.yml +++ b/.github/workflows/eas-build.yml @@ -31,6 +31,9 @@ jobs: build: name: EAS Build (${{ inputs.platform || 'all' }}) runs-on: ubuntu-latest + # Skip (neutral, not failed) when no Expo token is configured — EAS needs an + # account credential this repo can't provide by default. + if: ${{ secrets.EXPO_TOKEN != '' }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/eas-submit.yml b/.github/workflows/eas-submit.yml index 38d4712..69cbbb4 100644 --- a/.github/workflows/eas-submit.yml +++ b/.github/workflows/eas-submit.yml @@ -18,6 +18,8 @@ jobs: submit: name: Submit to ${{ inputs.platform == 'ios' && 'App Store' || 'Google Play' }} runs-on: ubuntu-latest + # Skip (neutral, not failed) when no Expo token is configured. + if: ${{ secrets.EXPO_TOKEN != '' }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/eas-update.yml b/.github/workflows/eas-update.yml index 11651f6..e3d0665 100644 --- a/.github/workflows/eas-update.yml +++ b/.github/workflows/eas-update.yml @@ -17,6 +17,8 @@ jobs: update: name: Publish OTA Update runs-on: ubuntu-latest + # Skip (neutral, not failed) when no Expo token is configured. + if: ${{ secrets.EXPO_TOKEN != '' }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index bcda7d0..faf19f4 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -20,27 +20,46 @@ jobs: steps: - uses: actions/checkout@v4 + # The integration stack (server/integration) is optional and not always + # present in the repo. Detect it up front and skip the server steps when + # absent so this job is a clean no-op instead of a hard failure on the + # missing working directory. + - name: Check for integration stack + id: stack + run: | + if [ -d server/integration ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::server/integration not present — skipping server integration tests." + fi + - uses: pnpm/action-setup@v4 + if: steps.stack.outputs.present == 'true' with: version: 10 - uses: actions/setup-node@v4 + if: steps.stack.outputs.present == 'true' with: node-version: 20 cache: pnpm # Install app + CLI dependencies - name: Install dependencies + if: steps.stack.outputs.present == 'true' run: pnpm install # Pre-compile the stack to a JSON artifact (surfaces any compile error in # its own step and lets the server boot without an esbuild compile). - name: Build integration artifact + if: steps.stack.outputs.present == 'true' run: npx objectstack build working-directory: server/integration # Start the ObjectStack CLI server (server/integration stack) in background - name: Start integration server + if: steps.stack.outputs.present == 'true' run: ./scripts/start-integration-server.sh --bg env: PORT: 4000 @@ -48,20 +67,22 @@ jobs: # Wait for server readiness - name: Wait for server + if: steps.stack.outputs.present == 'true' run: ./scripts/wait-for-server.sh http://localhost:4000/api/v1/auth/get-session 120 # Run integration tests - name: Run integration tests + if: steps.stack.outputs.present == 'true' run: pnpm test:integration:server env: INTEGRATION_SERVER_URL: http://localhost:4000 # Surface the server log for diagnosis if anything above failed - name: Dump server log - if: always() + if: always() && steps.stack.outputs.present == 'true' run: cat .integration-server.log || true # Stop server - name: Stop integration server - if: always() + if: always() && steps.stack.outputs.present == 'true' run: ./scripts/stop-integration-server.sh diff --git a/__tests__/e2e/app-navigation.e2e.test.tsx b/__tests__/e2e/app-navigation.e2e.test.tsx index ff625ef..fc73fe3 100644 --- a/__tests__/e2e/app-navigation.e2e.test.tsx +++ b/__tests__/e2e/app-navigation.e2e.test.tsx @@ -73,13 +73,17 @@ import SearchScreen from "~/app/(tabs)/search"; import AppsScreen from "~/app/(tabs)/apps"; import NotificationsScreen from "~/app/(tabs)/notifications"; import MoreScreen from "~/app/(tabs)/more"; +import { ToastProvider } from "~/components/ui/Toast"; +import { ConfirmProvider } from "~/components/ui/ConfirmDialog"; describe("E2E: App Navigation — Tab Screens", () => { it("renders Home tab header and empty dashboards state", async () => { const { getByText, findByText } = render(); - expect(getByText("Dashboard")).toBeTruthy(); - expect(getByText("Welcome back. Here's your overview.")).toBeTruthy(); + // Home leads with a time-of-day greeting (asserted via the stable subtitle) + // and surfaces the AI assistant entry. + expect(getByText("Here's your overview.")).toBeTruthy(); + expect(getByText("AI Assistant")).toBeTruthy(); // Apps publish no dashboards in this mock, so Home shows its empty state. expect(await findByText("No Dashboards")).toBeTruthy(); }); @@ -87,12 +91,8 @@ describe("E2E: App Navigation — Tab Screens", () => { it("renders Search tab with search input", () => { const { getByPlaceholderText, getByText } = render(); - expect( - getByPlaceholderText("Search objects, records..."), - ).toBeTruthy(); - expect( - getByText("Search across all your objects and records"), - ).toBeTruthy(); + expect(getByPlaceholderText("Search across all records…")).toBeTruthy(); + expect(getByText("Search across all your records")).toBeTruthy(); expect(getByText("Type to start searching")).toBeTruthy(); }); @@ -118,19 +118,26 @@ describe("E2E: App Navigation — Tab Screens", () => { }); it("renders More tab with menu sections", () => { - const { getByText } = render(); + // MoreScreen consumes the Toast + Confirm contexts (sign-out flow). + const { getByText } = render( + + + + + , + ); expect(getByText("Test User")).toBeTruthy(); expect(getByText("test@example.com")).toBeTruthy(); + // Section headers. + expect(getByText("Account")).toBeTruthy(); + expect(getByText("Assistant")).toBeTruthy(); + expect(getByText("Automation")).toBeTruthy(); expect(getByText("Preferences")).toBeTruthy(); - expect(getByText("Appearance")).toBeTruthy(); + // Menu items. + expect(getByText("Account & Security")).toBeTruthy(); + expect(getByText("AI Assistant")).toBeTruthy(); expect(getByText("Language")).toBeTruthy(); - expect(getByText("Security")).toBeTruthy(); - expect(getByText("Security & Privacy")).toBeTruthy(); - expect(getByText("Settings")).toBeTruthy(); - expect(getByText("Support")).toBeTruthy(); - expect(getByText("Help & Support")).toBeTruthy(); - expect(getByText("About")).toBeTruthy(); expect(getByText("Sign Out")).toBeTruthy(); }); }); diff --git a/__tests__/e2e/auth-flow.e2e.test.tsx b/__tests__/e2e/auth-flow.e2e.test.tsx index b25cdb0..8f1727a 100644 --- a/__tests__/e2e/auth-flow.e2e.test.tsx +++ b/__tests__/e2e/auth-flow.e2e.test.tsx @@ -6,7 +6,6 @@ */ import React from "react"; import { render, fireEvent, waitFor } from "@testing-library/react-native"; -import { Alert } from "react-native"; /* ---- Mocks ---- */ @@ -40,8 +39,6 @@ jest.mock("~/stores/server-store", () => ({ selector({ ssoProviders: ["google"] }), })); -jest.spyOn(Alert, "alert"); - import SignInScreen from "~/app/(auth)/sign-in"; describe("E2E: Authentication Flow", () => { @@ -67,10 +64,8 @@ describe("E2E: Authentication Flow", () => { fireEvent.press(getByText("Sign In")); - expect(Alert.alert).toHaveBeenCalledWith( - "Error", - "Please fill in all fields.", - ); + // The screen surfaces an inline error rather than a native Alert. + expect(getByText("Please enter your email and password.")).toBeTruthy(); expect(mockSignInEmail).not.toHaveBeenCalled(); }); @@ -119,10 +114,7 @@ describe("E2E: Authentication Flow", () => { fireEvent.press(getByText("Sign In")); await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - "Sign In Failed", - "Invalid credentials", - ); + expect(getByText("Invalid credentials")).toBeTruthy(); }); expect(mockReplace).not.toHaveBeenCalled(); }); @@ -143,10 +135,7 @@ describe("E2E: Authentication Flow", () => { fireEvent.press(getByText("Sign In")); await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - "Error", - "Something went wrong. Please try again.", - ); + expect(getByText("Something went wrong. Please try again.")).toBeTruthy(); }); }); diff --git a/__tests__/e2e/e2e.setup.ts b/__tests__/e2e/e2e.setup.ts new file mode 100644 index 0000000..ad1c76e --- /dev/null +++ b/__tests__/e2e/e2e.setup.ts @@ -0,0 +1,10 @@ +/** + * E2E test setup. + * + * Initialize i18n so the rendered screens show their real (English) copy rather + * than raw translation keys — the e2e suite renders full screens that call + * `useTranslation`, and without an initialized instance `t(key)` returns the + * key. Importing the app's i18n module runs its synchronous inline-resource + * init (no async backend), so translations resolve immediately. + */ +import "~/lib/i18n"; diff --git a/__tests__/e2e/record-list.e2e.test.tsx b/__tests__/e2e/record-list.e2e.test.tsx index f913b1f..66d2084 100644 --- a/__tests__/e2e/record-list.e2e.test.tsx +++ b/__tests__/e2e/record-list.e2e.test.tsx @@ -27,8 +27,8 @@ let mockAppDiscoveryReturn: { refetch: jest.Mock; }; -jest.mock("~/hooks/useAppDiscovery", () => ({ - useAppDiscovery: () => mockAppDiscoveryReturn, +jest.mock("~/hooks/useApps", () => ({ + useApps: () => mockAppDiscoveryReturn, })); import AppsScreen from "~/app/(tabs)/apps"; @@ -93,7 +93,7 @@ describe("E2E: Record List — App Discovery & Navigation", () => { const { getByText } = render(); - expect(getByText("Loading apps…")).toBeTruthy(); + expect(getByText("Loading your apps…")).toBeTruthy(); }); it("shows error state with retry button", () => { diff --git a/__tests__/lib/formatting.test.ts b/__tests__/lib/formatting.test.ts index 3bcd897..3d4c5a1 100644 --- a/__tests__/lib/formatting.test.ts +++ b/__tests__/lib/formatting.test.ts @@ -4,6 +4,7 @@ import { formatNumber, formatPercent, formatCurrency, + formatByPattern, } from "~/lib/formatting"; describe("formatDate", () => { @@ -66,3 +67,42 @@ describe("formatCurrency", () => { expect(result).toMatch(/€|EUR/); }); }); + +describe("formatDateTime — invalid input", () => { + it("returns the raw value for an unparseable date", () => { + expect(formatDateTime("nope")).toBe("nope"); + }); +}); + +describe("formatByPattern", () => { + it("falls back to a grouped number when no pattern is given", () => { + expect(formatByPattern(1234)).toBe("1,234"); + }); + + it("returns an em dash for non-finite values", () => { + expect(formatByPattern(Infinity, "$0,0")).toBe("—"); + expect(formatByPattern(NaN)).toBe("—"); + }); + + it("formats a percent pattern with the declared fraction digits", () => { + expect(formatByPattern(50, "0%")).toBe("50%"); + expect(formatByPattern(12.5, "0.0%")).toBe("12.5%"); + }); + + it("formats a currency pattern", () => { + expect(formatByPattern(1000, "$0,0")).toContain("$"); + expect(formatByPattern(1000, "$0,0")).toContain("1,000"); + expect(formatByPattern(9.99, "$0,0.00")).toContain("9.99"); + }); + + it("abbreviates with a compact notation pattern", () => { + expect(formatByPattern(1500, "0a")).toMatch(/1\.5\s?K/i); + const m = formatByPattern(1_500_000, "$0.0a"); + expect(m).toContain("$"); + expect(m).toMatch(/1\.5\s?M/i); + }); + + it("honours fraction digits in a plain decimal pattern", () => { + expect(formatByPattern(1234.5, "0,0.0")).toBe("1,234.5"); + }); +}); diff --git a/__tests__/lib/query-builder.test.ts b/__tests__/lib/query-builder.test.ts index f9a2dcf..e97ae3f 100644 --- a/__tests__/lib/query-builder.test.ts +++ b/__tests__/lib/query-builder.test.ts @@ -9,6 +9,7 @@ import { buildProjection, OPERATOR_META, resolveFilterMacro, + mongoFilterToAst, type FilterOperator, } from "~/lib/query-builder"; @@ -233,4 +234,85 @@ describe("resolveFilterMacro — week tokens", () => { it("leaves an unknown macro untouched (visibly inert, not silently zero)", () => { expect(resolveFilterMacro("{not_a_real_macro}")).toBe("{not_a_real_macro}"); }); + + it("passes through non-string and non-macro values unchanged", () => { + expect(resolveFilterMacro(42)).toBe(42); + expect(resolveFilterMacro("literal")).toBe("literal"); + }); + + it("resolves day/relative tokens", () => { + const day = 86_400_000; + const today = resolveFilterMacro("{today}") as number; + expect(typeof today).toBe("number"); + expect(resolveFilterMacro("{yesterday}")).toBe(today - day); + expect(resolveFilterMacro("{tomorrow}")).toBe(today + day); + expect(typeof resolveFilterMacro("{now}")).toBe("number"); + const ago = resolveFilterMacro("{3_days_ago}") as number; + expect(ago).toBeLessThan(Date.now()); + expect(typeof resolveFilterMacro("{last_2_months}")).toBe("number"); + expect(typeof resolveFilterMacro("{1_year_ago}")).toBe("number"); + }); + + it("resolves period-boundary tokens", () => { + for (const tok of [ + "{current_year_start}", + "{current_year_end}", + "{current_month_start}", + "{current_month_end}", + "{current_quarter_start}", + "{current_quarter_end}", + ]) { + expect(typeof resolveFilterMacro(tok)).toBe("number"); + } + }); +}); + +describe("mongoFilterToAst", () => { + it("returns undefined for an empty or absent filter", () => { + expect(mongoFilterToAst(undefined)).toBeUndefined(); + expect(mongoFilterToAst(null)).toBeUndefined(); + expect(mongoFilterToAst({})).toBeUndefined(); + }); + + it("translates a bare value to an equality node", () => { + expect(mongoFilterToAst({ status: "open" })).toEqual(["status", "=", "open"]); + }); + + it("translates Mongo operators to the AST symbol vocabulary", () => { + expect(mongoFilterToAst({ age: { $gte: 18 } })).toEqual(["age", ">=", 18]); + expect(mongoFilterToAst({ tag: { $in: ["a", "b"] } })).toEqual([ + "tag", + "in", + ["a", "b"], + ]); + }); + + it("ANDs multiple operators on one field", () => { + expect(mongoFilterToAst({ age: { $gte: 18, $lte: 65 } })).toEqual([ + "and", + ["age", ">=", 18], + ["age", "<=", 65], + ]); + }); + + it("ANDs multiple fields into a compound node", () => { + expect( + mongoFilterToAst({ is_completed: true, priority: "high" }), + ).toEqual([ + "and", + ["is_completed", "=", true], + ["priority", "=", "high"], + ]); + }); + + it("resolves date macros inside conditions", () => { + const ast = mongoFilterToAst({ created_at: { $gte: "{today}" } }) as unknown[]; + expect(ast[0]).toBe("created_at"); + expect(ast[1]).toBe(">="); + expect(typeof ast[2]).toBe("number"); + }); + + it("skips unknown operators", () => { + expect(mongoFilterToAst({ x: { $weird: 1 } })).toBeUndefined(); + }); }); diff --git a/jest.e2e.config.js b/jest.e2e.config.js index 47d4061..a022c11 100644 --- a/jest.e2e.config.js +++ b/jest.e2e.config.js @@ -13,5 +13,11 @@ module.exports = { ...baseConfig, testMatch: ["**/__tests__/e2e/**/*.e2e.test.(ts|tsx)"], testPathIgnorePatterns: ["/node_modules/", "/server/hotcrm/"], + // Initialize i18n on top of the base setup so rendered screens show their + // real copy instead of raw translation keys. + setupFilesAfterEnv: [ + ...baseConfig.setupFilesAfterEnv, + "/__tests__/e2e/e2e.setup.ts", + ], testTimeout: 10_000, };