Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5cdb54308 | ||
| a3f212ff81 | |||
|
|
de263f74b0 | ||
| a71e41302b |
@@ -118,7 +118,8 @@
|
||||
"mcp__localerrors__get_project",
|
||||
"mcp__localerrors__get_issue",
|
||||
"mcp__localerrors__get_event",
|
||||
"mcp__localerrors__list_teams"
|
||||
"mcp__localerrors__list_teams",
|
||||
"WebSearch"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@@ -222,6 +222,7 @@ Common issues with solutions:
|
||||
4. **Filename collisions** - Multer predictable names → Use `${Date.now()}-${Math.round(Math.random() * 1e9)}`
|
||||
5. **Response format mismatches** - API format changes → Log response bodies, update assertions
|
||||
6. **External service failures** - PM2/Redis unavailable → try/catch with graceful degradation
|
||||
7. **TZ environment variable breaks async hooks** - `TZ=America/Los_Angeles` causes `RangeError: Invalid triggerAsyncId value: NaN` → Tests now explicitly set `TZ=` (empty) in package.json scripts
|
||||
|
||||
**Full Details**: See test issues section at end of this document or [docs/development/TESTING.md](docs/development/TESTING.md)
|
||||
|
||||
@@ -377,3 +378,28 @@ API formats change: `data.jobId` vs `data.job.id`, nested vs flat, string vs num
|
||||
PM2/Redis health checks fail when unavailable.
|
||||
|
||||
**Solution**: try/catch with graceful degradation or mock
|
||||
|
||||
### 7. TZ Environment Variable Breaking Async Hooks
|
||||
|
||||
**Problem**: When `TZ=America/Los_Angeles` (or other timezone values) is set in the environment, Node.js async_hooks module can produce `RangeError: Invalid triggerAsyncId value: NaN`. This breaks React Testing Library's `render()` function which uses async hooks internally.
|
||||
|
||||
**Root Cause**: Setting `TZ` to certain timezone values interferes with Node.js's internal async tracking mechanism, causing invalid async IDs to be generated.
|
||||
|
||||
**Symptoms**:
|
||||
|
||||
```text
|
||||
RangeError: Invalid triggerAsyncId value: NaN
|
||||
❯ process.env.NODE_ENV.queueSeveralMicrotasks node_modules/react/cjs/react.development.js:751:15
|
||||
❯ process.env.NODE_ENV.exports.act node_modules/react/cjs/react.development.js:886:11
|
||||
❯ node_modules/@testing-library/react/dist/act-compat.js:46:25
|
||||
❯ renderRoot node_modules/@testing-library/react/dist/pure.js:189:26
|
||||
```
|
||||
|
||||
**Solution**: Explicitly unset `TZ` in all test scripts by adding `TZ=` (empty value) to cross-env:
|
||||
|
||||
```json
|
||||
"test:unit": "cross-env NODE_ENV=test TZ= tsx ..."
|
||||
"test:integration": "cross-env NODE_ENV=test TZ= tsx ..."
|
||||
```
|
||||
|
||||
**Context**: This issue was introduced in commit `d03900c` which added `TZ: 'America/Los_Angeles'` to PM2 ecosystem configs for consistent log timestamps in production/dev environments. Tests must explicitly override this to prevent the async hooks error.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.11",
|
||||
"version": "0.12.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.12.11",
|
||||
"version": "0.12.13",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.12.11",
|
||||
"version": "0.12.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
@@ -14,12 +14,12 @@
|
||||
"start": "npm run start:prod",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run",
|
||||
"test-wsl": "cross-env NODE_ENV=test vitest run",
|
||||
"test": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx ./node_modules/vitest/vitest.mjs run",
|
||||
"test-wsl": "cross-env NODE_ENV=test TZ= vitest run",
|
||||
"test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage",
|
||||
"test:unit": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
|
||||
"test:integration": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
|
||||
"test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts",
|
||||
"test:unit": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
|
||||
"test:integration": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
|
||||
"test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test TZ= tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
|
||||
@@ -27,9 +27,13 @@ const defaultProps = {
|
||||
};
|
||||
|
||||
const setupSuccessMocks = () => {
|
||||
// The API returns {success, data: {userprofile, token}}, and the mutation extracts .data
|
||||
const mockAuthResponse = {
|
||||
userprofile: createMockUserProfile({ user: { user_id: '123', email: 'test@example.com' } }),
|
||||
token: 'mock-token',
|
||||
success: true,
|
||||
data: {
|
||||
userprofile: createMockUserProfile({ user: { user_id: '123', email: 'test@example.com' } }),
|
||||
token: 'mock-token',
|
||||
},
|
||||
};
|
||||
(mockedApiClient.loginUser as Mock).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockAuthResponse)),
|
||||
|
||||
@@ -82,7 +82,11 @@ const defaultAuthenticatedProps = {
|
||||
};
|
||||
|
||||
const setupSuccessMocks = () => {
|
||||
const mockAuthResponse = { userprofile: authenticatedProfile, token: 'mock-token' };
|
||||
// The API returns {success, data: {userprofile, token}}, and the mutation extracts .data
|
||||
const mockAuthResponse = {
|
||||
success: true,
|
||||
data: { userprofile: authenticatedProfile, token: 'mock-token' },
|
||||
};
|
||||
(mockedApiClient.loginUser as Mock).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockAuthResponse)),
|
||||
);
|
||||
|
||||
@@ -132,7 +132,8 @@ describe('API Client', () => {
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ token: 'new-refreshed-token' }),
|
||||
// The API returns {success, data: {token}} wrapper format
|
||||
json: () => Promise.resolve({ success: true, data: { token: 'new-refreshed-token' } }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -218,7 +219,7 @@ describe('API Client', () => {
|
||||
localStorage.setItem('authToken', 'expired-token');
|
||||
// Mock the global fetch to return a sequence of responses:
|
||||
// 1. 401 Unauthorized (initial API call)
|
||||
// 2. 200 OK (token refresh call)
|
||||
// 2. 200 OK (token refresh call) - uses API wrapper format {success, data: {token}}
|
||||
// 3. 200 OK (retry of the initial API call)
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -229,7 +230,8 @@ describe('API Client', () => {
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ token: 'new-refreshed-token' }),
|
||||
// The API returns {success, data: {token}} wrapper format
|
||||
json: () => Promise.resolve({ success: true, data: { token: 'new-refreshed-token' } }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -62,12 +62,33 @@ vi.mock('./logger.server', () => ({
|
||||
vi.mock('bullmq', () => ({
|
||||
Worker: mocks.MockWorker,
|
||||
Queue: vi.fn(function () {
|
||||
return { add: vi.fn() };
|
||||
return { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
// Add UnrecoverableError to the mock so it can be used in tests
|
||||
UnrecoverableError: class UnrecoverableError extends Error {},
|
||||
}));
|
||||
|
||||
// Mock redis.server to prevent real Redis connection attempts
|
||||
vi.mock('./redis.server', () => ({
|
||||
connection: {
|
||||
on: vi.fn(),
|
||||
quit: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock queues.server to provide mock queue instances
|
||||
vi.mock('./queues.server', () => ({
|
||||
flyerQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
emailQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
analyticsQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
cleanupQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
weeklyAnalyticsQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
tokenCleanupQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
receiptQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
expiryAlertQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
barcodeQueue: { add: vi.fn(), close: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
// Mock flyerProcessingService.server as flyerWorker and cleanupWorker depend on it
|
||||
vi.mock('./flyerProcessingService.server', () => {
|
||||
// Mock the constructor to return an object with the mocked methods
|
||||
@@ -88,6 +109,67 @@ vi.mock('./flyerDataTransformer', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock aiService.server to prevent initialization issues
|
||||
vi.mock('./aiService.server', () => ({
|
||||
aiService: {
|
||||
extractAndValidateData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock db/index.db to prevent database connections
|
||||
vi.mock('./db/index.db', () => ({
|
||||
personalizationRepo: {},
|
||||
}));
|
||||
|
||||
// Mock flyerAiProcessor.server
|
||||
vi.mock('./flyerAiProcessor.server', () => ({
|
||||
FlyerAiProcessor: vi.fn().mockImplementation(function () {
|
||||
return { processFlyer: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock flyerPersistenceService.server
|
||||
vi.mock('./flyerPersistenceService.server', () => ({
|
||||
FlyerPersistenceService: vi.fn().mockImplementation(function () {
|
||||
return { persistFlyerData: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock db/connection.db to prevent database connections
|
||||
vi.mock('./db/connection.db', () => ({
|
||||
withTransaction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock receiptService.server
|
||||
vi.mock('./receiptService.server', () => ({
|
||||
processReceiptJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock expiryService.server
|
||||
vi.mock('./expiryService.server', () => ({
|
||||
processExpiryAlertJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock barcodeService.server
|
||||
vi.mock('./barcodeService.server', () => ({
|
||||
processBarcodeDetectionJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock flyerFileHandler.server
|
||||
vi.mock('./flyerFileHandler.server', () => ({
|
||||
FlyerFileHandler: vi.fn().mockImplementation(function () {
|
||||
return { handleFile: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock workerOptions config
|
||||
vi.mock('../config/workerOptions', () => ({
|
||||
defaultWorkerOptions: {
|
||||
lockDuration: 30000,
|
||||
stalledInterval: 30000,
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper to create a mock BullMQ Job object
|
||||
const createMockJob = <T>(data: T): Job<T> => {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user