diff --git a/package-lock.json b/package-lock.json
index 3e952a6..a893833 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -57,6 +57,7 @@
"@semantic-release/npm": "^12.0.2",
"@semantic-release/release-notes-generator": "^14.1.0",
"@size-limit/file": "^11.1.4",
+ "@testing-library/react-native": "13.3.3",
"@typechain/ethers-v5": "^11.1.2",
"@types/detox": "^17.14.3",
"@types/jest": "^29.5.14",
@@ -75,6 +76,7 @@
"jest-expo": "~53.0.5",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
+ "react-test-renderer": "19.2.5",
"semantic-release": "^24.2.9",
"size-limit": "^11.1.4",
"ts-jest": "^29.4.11",
@@ -5839,21 +5841,6 @@
"@babel/core": "*"
}
},
- "node_modules/@react-native/codegen/node_modules/hermes-estree": {
- "version": "0.33.3",
- "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
- "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
- "license": "MIT"
- },
- "node_modules/@react-native/codegen/node_modules/hermes-parser": {
- "version": "0.33.3",
- "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
- "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
- "license": "MIT",
- "dependencies": {
- "hermes-estree": "0.33.3"
- }
- },
"node_modules/@react-native/community-cli-plugin": {
"version": "0.85.2",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.2.tgz",
@@ -8437,6 +8424,124 @@
}
}
},
+ "node_modules/@testing-library/react-native": {
+ "version": "13.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz",
+ "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jest-matcher-utils": "^30.0.5",
+ "picocolors": "^1.1.1",
+ "pretty-format": "^30.0.5",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "jest": ">=29.0.0",
+ "react": ">=18.2.0",
+ "react-native": ">=0.71",
+ "react-test-renderer": ">=18.2.0"
+ },
+ "peerDependenciesMeta": {
+ "jest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/react-native/node_modules/@jest/diff-sequences": {
+ "version": "30.4.0",
+ "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz",
+ "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@testing-library/react-native/node_modules/@jest/schemas": {
+ "version": "30.4.1",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz",
+ "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.34.0"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@testing-library/react-native/node_modules/@sinclair/typebox": {
+ "version": "0.34.49",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
+ "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react-native/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@testing-library/react-native/node_modules/jest-diff": {
+ "version": "30.4.1",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz",
+ "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/diff-sequences": "30.4.0",
+ "@jest/get-type": "30.1.0",
+ "chalk": "^4.1.2",
+ "pretty-format": "30.4.1"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@testing-library/react-native/node_modules/jest-matcher-utils": {
+ "version": "30.4.1",
+ "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz",
+ "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/get-type": "30.1.0",
+ "chalk": "^4.1.2",
+ "jest-diff": "30.4.1",
+ "pretty-format": "30.4.1"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@testing-library/react-native/node_modules/pretty-format": {
+ "version": "30.4.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
+ "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "30.4.1",
+ "ansi-styles": "^5.2.0",
+ "react-is-18": "npm:react-is@^18.3.1",
+ "react-is-19": "npm:react-is@^19.2.5"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -11795,12 +11900,12 @@
"integrity": "sha512-4hHoto6xaN23LCyZgL9LJZc3olmAxd7b6jDzlZnKXAh4rRAbZRKNBJoOOdp46OBqgy+K0t0guTj5/mhA8inymQ=="
},
"node_modules/babel-plugin-syntax-hermes-parser": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.25.1.tgz",
- "integrity": "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ==",
+ "version": "0.33.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
+ "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
"license": "MIT",
"dependencies": {
- "hermes-parser": "0.25.1"
+ "hermes-parser": "0.33.3"
}
},
"node_modules/babel-plugin-transform-flow-enums": {
@@ -18938,18 +19043,18 @@
"license": "MIT"
},
"node_modules/hermes-estree": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
- "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "version": "0.33.3",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
+ "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
"license": "MIT"
},
"node_modules/hermes-parser": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
- "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "version": "0.33.3",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
+ "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
"license": "MIT",
"dependencies": {
- "hermes-estree": "0.25.1"
+ "hermes-estree": "0.33.3"
}
},
"node_modules/highlight.js": {
@@ -24554,6 +24659,16 @@
"dom-walk": "^0.1.0"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -29984,6 +30099,22 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/react-is-18": {
+ "name": "react-is",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-is-19": {
+ "name": "react-is",
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
+ "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react-native": {
"version": "0.85.2",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.85.2.tgz",
@@ -30354,15 +30485,6 @@
"react-native": "*"
}
},
- "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": {
- "version": "0.33.3",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
- "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
- "license": "MIT",
- "dependencies": {
- "hermes-parser": "0.33.3"
- }
- },
"node_modules/react-native/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -30372,21 +30494,6 @@
"node": ">=18"
}
},
- "node_modules/react-native/node_modules/hermes-estree": {
- "version": "0.33.3",
- "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
- "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
- "license": "MIT"
- },
- "node_modules/react-native/node_modules/hermes-parser": {
- "version": "0.33.3",
- "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
- "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
- "license": "MIT",
- "dependencies": {
- "hermes-estree": "0.33.3"
- }
- },
"node_modules/react-native/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -30435,6 +30542,34 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-test-renderer": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.2.5.tgz",
+ "integrity": "sha512-kwViRpdISMTpcpy5B6TSewfJzRjnajihRaj57ZmOWKD+SPN6k9LUM13O0pfOuW8ir6B6OOiAXwCRqOoVxRNykA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "react-is": "^19.2.5",
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.5"
+ }
+ },
+ "node_modules/react-test-renderer/node_modules/react-is": {
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
+ "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-test-renderer/node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
@@ -30643,6 +30778,33 @@
"node": ">= 12.13.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/redent/node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reduce-flatten": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz",
diff --git a/package.json b/package.json
index fb96d7a..1ac5c93 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,7 @@
"@semantic-release/npm": "^12.0.2",
"@semantic-release/release-notes-generator": "^14.1.0",
"@size-limit/file": "^11.1.4",
+ "@testing-library/react-native": "13.3.3",
"@typechain/ethers-v5": "^11.1.2",
"@types/detox": "^17.14.3",
"@types/jest": "^29.5.14",
@@ -113,6 +114,7 @@
"jest-expo": "~53.0.5",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
+ "react-test-renderer": "19.2.5",
"semantic-release": "^24.2.9",
"size-limit": "^11.1.4",
"ts-jest": "^29.4.11",
@@ -120,6 +122,10 @@
"typescript": "~5.8.3"
},
"private": false,
+ "overrides": {
+ "hermes-parser": "0.33.3",
+ "babel-plugin-syntax-hermes-parser": "0.33.3"
+ },
"repository": {
"type": "git",
"url": "https://github.com/Smartdevs17/SubTrackr.git"
diff --git a/src/__fixtures__/subscriptions.ts b/src/__fixtures__/subscriptions.ts
new file mode 100644
index 0000000..45fc6dd
--- /dev/null
+++ b/src/__fixtures__/subscriptions.ts
@@ -0,0 +1,57 @@
+import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription';
+
+/**
+ * Shared test data for component interaction tests.
+ *
+ * Use these fixtures instead of inlining subscription objects in individual
+ * test cases so that the shape stays consistent with the real `Subscription`
+ * type across every suite.
+ */
+export const mockSubscription: Subscription = {
+ id: 'test-id-001',
+ name: 'Netflix',
+ description: 'Streaming service',
+ category: SubscriptionCategory.STREAMING,
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-06-30T00:00:00.000Z'),
+ isActive: true,
+ isCryptoEnabled: false,
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2026-01-01T00:00:00.000Z'),
+};
+
+export const mockPausedSubscription: Subscription = {
+ id: 'test-id-002',
+ name: 'Spotify',
+ description: 'Music streaming',
+ category: SubscriptionCategory.STREAMING,
+ price: 9.99,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-06-15T00:00:00.000Z'),
+ isActive: false,
+ isCryptoEnabled: false,
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2026-01-01T00:00:00.000Z'),
+};
+
+export const mockSubscriptions: Subscription[] = [
+ mockSubscription,
+ mockPausedSubscription,
+ {
+ id: 'test-id-003',
+ name: 'iCloud',
+ description: 'Cloud storage',
+ category: SubscriptionCategory.PRODUCTIVITY,
+ price: 2.99,
+ currency: 'USD',
+ billingCycle: BillingCycle.YEARLY,
+ nextBillingDate: new Date('2026-07-01T00:00:00.000Z'),
+ isActive: true,
+ isCryptoEnabled: false,
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2026-01-01T00:00:00.000Z'),
+ },
+];
diff --git a/src/components/common/Button.test.tsx b/src/components/common/Button.test.tsx
new file mode 100644
index 0000000..1cab517
--- /dev/null
+++ b/src/components/common/Button.test.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import { Button } from './Button';
+
+describe('Button', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('press interactions', () => {
+ it('calls the onPress callback once when the button is pressed', () => {
+ const onPress = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByText('Save'));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onPress when the button is disabled', () => {
+ const onPress = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByText('Save'));
+
+ expect(onPress).not.toHaveBeenCalled();
+ });
+
+ it('does not call onPress while the button is loading', () => {
+ const onPress = jest.fn();
+ render();
+
+ // While loading the label is replaced by a spinner, so query by role.
+ fireEvent.press(screen.getByRole('button'));
+
+ expect(onPress).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('accessibility state', () => {
+ it('exposes a disabled accessibility state when disabled', () => {
+ render();
+
+ const button = screen.getByRole('button');
+
+ expect(button.props.accessibilityState).toMatchObject({ disabled: true });
+ });
+
+ it('marks the button as busy while loading', () => {
+ render();
+
+ const button = screen.getByRole('button');
+
+ expect(button.props.accessibilityState).toMatchObject({ busy: true });
+ });
+ });
+});
diff --git a/src/components/common/FloatingActionButton.test.tsx b/src/components/common/FloatingActionButton.test.tsx
new file mode 100644
index 0000000..cc75312
--- /dev/null
+++ b/src/components/common/FloatingActionButton.test.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import { FloatingActionButton } from './FloatingActionButton';
+
+describe('FloatingActionButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls onPress once when the button is pressed', () => {
+ const onPress = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByTestId('fab'));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders the provided icon and title', () => {
+ render();
+
+ expect(screen.getByText('★')).toBeTruthy();
+ expect(screen.getByText('Add')).toBeTruthy();
+ });
+
+ it('uses the accessibility label when provided', () => {
+ const onPress = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByLabelText('Add subscription'));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/home/FilterBar.test.tsx b/src/components/home/FilterBar.test.tsx
new file mode 100644
index 0000000..51feb28
--- /dev/null
+++ b/src/components/home/FilterBar.test.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { render, screen, fireEvent } from '../../test-utils';
+import { FilterBar } from './FilterBar';
+
+describe('FilterBar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const defaultProps = {
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ onFilterPress: jest.fn(),
+ hasActiveFilters: false,
+ activeFilterCount: 0,
+ };
+
+ describe('search interactions', () => {
+ it('calls setSearchQuery with the typed text', () => {
+ const setSearchQuery = jest.fn();
+ render();
+
+ fireEvent.changeText(screen.getByPlaceholderText('Search subscriptions...'), 'netflix');
+
+ expect(setSearchQuery).toHaveBeenCalledWith('netflix');
+ });
+
+ it('clears the search query when the clear button is pressed', () => {
+ const setSearchQuery = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByLabelText('Clear search'));
+
+ expect(setSearchQuery).toHaveBeenCalledWith('');
+ });
+
+ it('does not render a clear button when the query is empty', () => {
+ render();
+
+ expect(screen.queryByLabelText('Clear search')).toBeNull();
+ });
+ });
+
+ describe('filter interactions', () => {
+ it('calls onFilterPress when the filter button is pressed', () => {
+ const onFilterPress = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByLabelText('Filters'));
+
+ expect(onFilterPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows the active filter count in the accessibility label and badge', () => {
+ render();
+
+ // Badge text is visible to the user...
+ expect(screen.getByText('3')).toBeTruthy();
+ // ...and the active count is announced to assistive tech.
+ expect(screen.getByLabelText('Filters, 3 active')).toBeTruthy();
+ });
+ });
+});
diff --git a/src/components/subscription/SubscriptionCard.test.tsx b/src/components/subscription/SubscriptionCard.test.tsx
new file mode 100644
index 0000000..f73ce61
--- /dev/null
+++ b/src/components/subscription/SubscriptionCard.test.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { Alert } from 'react-native';
+import { render, screen, fireEvent } from '../../test-utils';
+import { SubscriptionCard } from './SubscriptionCard';
+import { mockSubscription, mockPausedSubscription } from '../../__fixtures__/subscriptions';
+
+// SubscriptionCard reads the settings store, which persists through AsyncStorage.
+// The native module is unavailable under Jest, so use the library's official mock.
+jest.mock('@react-native-async-storage/async-storage', () =>
+ require('@react-native-async-storage/async-storage/jest/async-storage-mock')
+);
+
+describe('SubscriptionCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('press interactions', () => {
+ it('calls onPress with the full subscription when the card is pressed', () => {
+ const onPress = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByTestId(`subscription-card-${mockSubscription.id}`));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(onPress).toHaveBeenCalledWith(mockSubscription);
+ });
+
+ it('renders the subscription name', () => {
+ render();
+
+ expect(screen.getByTestId(`subscription-name-${mockSubscription.id}`)).toHaveTextContent(
+ 'Netflix'
+ );
+ });
+ });
+
+ describe('status toggle interactions', () => {
+ it('does not render the toggle button when onToggleStatus is omitted', () => {
+ render();
+
+ expect(screen.queryByTestId(`subscription-toggle-${mockSubscription.id}`)).toBeNull();
+ });
+
+ it('prompts for confirmation and calls onToggleStatus when confirmed', () => {
+ const onToggleStatus = jest.fn();
+ const alertSpy = jest.spyOn(Alert, 'alert');
+ render(
+
+ );
+
+ fireEvent.press(screen.getByTestId(`subscription-toggle-${mockSubscription.id}`));
+
+ // A confirmation dialog is shown before the status changes.
+ expect(alertSpy).toHaveBeenCalledTimes(1);
+ expect(onToggleStatus).not.toHaveBeenCalled();
+
+ // Simulate the user tapping "Confirm" in the alert.
+ const buttons = alertSpy.mock.calls[0][2] ?? [];
+ const confirm = buttons.find((button) => button.text === 'Confirm');
+ confirm?.onPress?.();
+
+ expect(onToggleStatus).toHaveBeenCalledWith(mockSubscription.id);
+ });
+
+ it('labels the toggle "Activate" for a paused subscription', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByTestId(`subscription-toggle-${mockPausedSubscription.id}`)
+ ).toHaveTextContent('Activate');
+ });
+ });
+});
diff --git a/src/screens/AddSubscriptionScreen.test.tsx b/src/screens/AddSubscriptionScreen.test.tsx
new file mode 100644
index 0000000..368fb0d
--- /dev/null
+++ b/src/screens/AddSubscriptionScreen.test.tsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { Alert } from 'react-native';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
+import AddSubscriptionScreen from './AddSubscriptionScreen';
+import { useSubscriptionStore, useSettingsStore } from '../store';
+
+// Navigation is mocked so the screen can be rendered in isolation without a
+// real navigator. requireActual keeps the rest of the package intact.
+const mockGoBack = jest.fn();
+const mockNavigate = jest.fn();
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ goBack: mockGoBack,
+ navigate: mockNavigate,
+ }),
+}));
+
+// The store hooks are mocked so each test controls the exact state the screen
+// reads, and we can assert on the addSubscription action.
+jest.mock('../store', () => ({
+ useSubscriptionStore: jest.fn(),
+ useSettingsStore: jest.fn(),
+}));
+
+// errorHandler turns thrown validation Errors into a user-facing message.
+jest.mock('../services/errorHandler', () => ({
+ errorHandler: {
+ handleError: (error: Error) => ({ userMessage: error.message }),
+ },
+}));
+
+// The native date picker is never opened in these tests; stub it so importing
+// the screen does not pull in the native module.
+jest.mock('@react-native-community/datetimepicker', () => 'DateTimePicker');
+
+const mockAddSubscription = jest.fn();
+
+describe('AddSubscriptionScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ (useSubscriptionStore as unknown as jest.Mock).mockReturnValue({
+ addSubscription: mockAddSubscription,
+ isLoading: false,
+ error: null,
+ });
+ (useSettingsStore as unknown as jest.Mock).mockReturnValue({
+ preferredCurrency: 'USD',
+ });
+ });
+
+ describe('form validation', () => {
+ it('shows a validation error and does not submit when the name is empty', () => {
+ const alertSpy = jest.spyOn(Alert, 'alert');
+ render();
+
+ fireEvent.press(screen.getByTestId('save-subscription-button'));
+
+ expect(alertSpy).toHaveBeenCalledWith('Validation Error', 'Subscription name is required');
+ expect(mockAddSubscription).not.toHaveBeenCalled();
+ });
+
+ it('shows an inline error when the price is not a valid number', () => {
+ render();
+
+ fireEvent.changeText(screen.getByTestId('subscription-price-input'), 'abc');
+
+ expect(screen.getByText('Price must be a valid number')).toBeTruthy();
+ });
+
+ it('does not submit when the name is provided but the price is missing', () => {
+ const alertSpy = jest.spyOn(Alert, 'alert');
+ render();
+
+ fireEvent.changeText(screen.getByTestId('subscription-name-input'), 'Netflix');
+ fireEvent.press(screen.getByTestId('save-subscription-button'));
+
+ expect(alertSpy).toHaveBeenCalledWith('Validation Error', expect.stringMatching(/price/i));
+ expect(mockAddSubscription).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('successful submission', () => {
+ it('calls addSubscription with the entered data when the form is valid', async () => {
+ mockAddSubscription.mockResolvedValueOnce(undefined);
+ render();
+
+ fireEvent.changeText(screen.getByTestId('subscription-name-input'), 'Netflix');
+ fireEvent.changeText(screen.getByTestId('subscription-price-input'), '15.99');
+ fireEvent.press(screen.getByTestId('save-subscription-button'));
+
+ await waitFor(() => {
+ expect(mockAddSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Netflix',
+ price: 15.99,
+ currency: 'USD',
+ })
+ );
+ });
+ });
+ });
+
+ describe('navigation', () => {
+ it('navigates back when cancel is pressed on an empty form', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('cancel-add-subscription-button'));
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('asks to discard changes instead of navigating back when the form is dirty', () => {
+ const alertSpy = jest.spyOn(Alert, 'alert');
+ render();
+
+ fireEvent.changeText(screen.getByTestId('subscription-name-input'), 'Netflix');
+ fireEvent.press(screen.getByTestId('cancel-add-subscription-button'));
+
+ expect(alertSpy).toHaveBeenCalledWith(
+ 'Discard Changes',
+ expect.any(String),
+ expect.any(Array)
+ );
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/test-utils.tsx b/src/test-utils.tsx
new file mode 100644
index 0000000..e6d5214
--- /dev/null
+++ b/src/test-utils.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { render, RenderOptions } from '@testing-library/react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { SafeAreaProvider, initialWindowMetrics, Metrics } from 'react-native-safe-area-context';
+
+// Under Jest there is no native layout pass, so `initialWindowMetrics` is null
+// and `SafeAreaProvider` would withhold its children until an onLayout event
+// that never fires. Providing concrete metrics makes it render synchronously.
+const TEST_METRICS: Metrics = {
+ frame: { x: 0, y: 0, width: 390, height: 844 },
+ insets: { top: 47, left: 0, right: 0, bottom: 34 },
+};
+
+/**
+ * Shared render wrapper for component interaction tests.
+ *
+ * Wraps the component under test in the providers the app relies on at runtime
+ * (safe-area + navigation context) so individual suites never have to repeat
+ * provider setup. Screens that need a fully mocked `useNavigation` should mock
+ * `@react-navigation/native` directly; the real `NavigationContainer` here is a
+ * harmless no-op in that case.
+ */
+interface WrapperProps {
+ children: React.ReactNode;
+}
+
+const AllProviders = ({ children }: WrapperProps) => (
+
+ {children}
+
+);
+
+const customRender = (ui: React.ReactElement, options?: RenderOptions) =>
+ render(ui, { wrapper: AllProviders, ...options });
+
+// Re-export everything from RNTL so suites import from a single place.
+export * from '@testing-library/react-native';
+
+// Override `render` with the provider-wrapped version.
+export { customRender as render };