Compare commits

..

30 Commits

Author SHA1 Message Date
Gitea Actions
3ae7b9e0d4 ci: Bump version to 0.0.17 [skip ci] 2025-12-24 04:51:21 +05:00
921c48fc57 more unit test fixes now the UseProfileAddress OOM has been identified
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m14s
2025-12-23 15:50:01 -08:00
Gitea Actions
2571864b91 ci: Bump version to 0.0.16 [skip ci] 2025-12-24 01:52:55 +05:00
065d0c746a Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m17s
2025-12-23 12:52:02 -08:00
395f6c21a2 some "push to get dev working" stuff, and possibly found the problem test 2025-12-23 12:51:56 -08:00
Gitea Actions
aec56dfc23 ci: Bump version to 0.0.15 [skip ci] 2025-12-24 01:18:44 +05:00
a12a0e5207 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m28s
2025-12-23 12:17:31 -08:00
e337bd67b1 some "push to get dev working" stuff, and possibly found the problem test 2025-12-23 12:17:21 -08:00
Gitea Actions
a8f5b4e51a ci: Bump version to 0.0.14 [skip ci] 2025-12-23 08:45:00 +05:00
d0ce8021d6 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m5s
2025-12-22 19:44:11 -08:00
efbb162880 keep disabling tests until the culprit is found this fucking sucks 2025-12-22 19:44:02 -08:00
Gitea Actions
e353ce8a81 ci: Bump version to 0.0.13 [skip ci] 2025-12-23 08:30:20 +05:00
b5cbf271b8 debugging the fucking OOM
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m51s
2025-12-22 19:29:10 -08:00
Gitea Actions
2041b4ac3c ci: Bump version to 0.0.12 [skip ci] 2025-12-23 08:19:32 +05:00
e547363a65 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-22 19:19:05 -08:00
bddaf765fc disable to stupid system test that fails - fuck it 2025-12-22 19:18:29 -08:00
Gitea Actions
3c0bebb65c ci: Bump version to 0.0.11 [skip ci] 2025-12-23 07:59:09 +05:00
265cc3ffd4 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m37s
2025-12-22 18:58:11 -08:00
3d5767b60b roll back changes to src/routes/system.routes.ts hopefully before OOM issues 2025-12-22 18:58:01 -08:00
Gitea Actions
e9cb45efe0 ci: Bump version to 0.0.10 [skip ci] 2025-12-23 07:41:54 +05:00
99a57f3a30 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m47s
2025-12-22 18:40:59 -08:00
e46f5eb7f6 roll back changes to src/routes/system.routes.test.ts hopefully before OOM issues 2025-12-22 18:40:37 -08:00
Gitea Actions
034887069c ci: Bump version to 0.0.9 [skip ci] 2025-12-23 07:23:30 +05:00
84b5e0e15e Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-22 18:22:23 -08:00
dc0f774699 try to stop system.route test crashes fuck sakes 2025-12-22 18:21:39 -08:00
Gitea Actions
1195b7e87f ci: Bump version to 0.0.8 [skip ci] 2025-12-23 04:45:54 +05:00
e9889f1f1e Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m41s
2025-12-22 15:45:11 -08:00
3c7f6429aa try to stop system.route test crashes 2025-12-22 15:43:59 -08:00
Gitea Actions
0db90dfaa6 ci: Bump version to 0.0.7 [skip ci] 2025-12-23 04:33:19 +05:00
b7a1294ae6 fix to versioning
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running
2025-12-22 15:32:43 -08:00
17 changed files with 2251 additions and 2609 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "Flyer Crawler Dev (Ubuntu 22.04)",
"dockerComposeFile": ["../compose.dev.yml"],
"service": "app",
"workspaceFolder": "/app",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
},
"remoteUser": "root",
// Automatically install dependencies when the container is created.
// This runs inside the container, populating the isolated node_modules volume.
"postCreateCommand": "npm install",
"postAttachCommand": "npm run dev:container",
// Try to start podman machine, but exit with success (0) even if it's already running
"initializeCommand": "powershell -Command \"podman machine start; exit 0\""
}

View File

@@ -51,7 +51,14 @@ jobs:
# Bump the patch version number. This creates a new commit and a new tag. # Bump the patch version number. This creates a new commit and a new tag.
# The commit message includes [skip ci] to prevent this push from triggering another workflow run. # The commit message includes [skip ci] to prevent this push from triggering another workflow run.
npm version patch -m "ci: Bump version to %s [skip ci]" # If the tag already exists (e.g. re-running a failed job), we skip the conflicting version.
if ! npm version patch -m "ci: Bump version to %s [skip ci]"; then
echo "⚠️ Version bump failed (likely tag exists). Attempting to skip to next version..."
# Bump package.json to the conflicting version without git tagging
npm version patch --no-git-tag-version > /dev/null
# Bump again to the next version, forcing it because the directory is now dirty
npm version patch -m "ci: Bump version to %s [skip ci]" --force
fi
# Push the new commit and the new tag back to the main branch. # Push the new commit and the new tag back to the main branch.
git push --follow-tags git push --follow-tags
@@ -129,7 +136,8 @@ jobs:
# Run unit and integration tests as separate steps. # Run unit and integration tests as separate steps.
# The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run. # The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run.
echo "--- Running Unit Tests ---" echo "--- Running Unit Tests ---"
npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true # npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true
npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only --no-file-parallelism || true
echo "--- Running Integration Tests ---" echo "--- Running Integration Tests ---"
npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true

31
Dockerfile.dev Normal file
View File

@@ -0,0 +1,31 @@
# Use Ubuntu 22.04 (LTS) as the base image to match production
FROM ubuntu:22.04
# Set environment variables to non-interactive to avoid prompts during installation
ENV DEBIAN_FRONTEND=noninteractive
# Update package lists and install essential tools
# - curl: for downloading Node.js setup script
# - git: for version control operations
# - build-essential: for compiling native Node.js modules (node-gyp)
# - python3: required by some Node.js build tools
RUN apt-get update && apt-get install -y \
curl \
git \
build-essential \
python3 \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 20.x (LTS) from NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
# Set the working directory inside the container
WORKDIR /app
# Set default environment variables for development
ENV NODE_ENV=development
ENV NODE_OPTIONS='--max-old-space-size=8192'
# Default command keeps the container running so you can attach to it
CMD ["bash"]

52
compose.dev.yml Normal file
View File

@@ -0,0 +1,52 @@
version: '3.8'
services:
app:
container_name: flyer-crawler-dev
build:
context: .
dockerfile: Dockerfile.dev
volumes:
# Mount the current directory to /app in the container
- .:/app
# Create a volume for node_modules to avoid conflicts with Windows host
# and improve performance.
- node_modules_data:/app/node_modules
ports:
- '3000:3000' # Frontend (Vite default)
- '3001:3001' # Backend API
environment:
- NODE_ENV=development
- DB_HOST=postgres
- DB_USER=postgres
- DB_PASSWORD=postgres
- DB_NAME=flyer_crawler_dev
- REDIS_URL=redis://redis:6379
# Add other secrets here or use a .env file
depends_on:
- postgres
- redis
# Keep container running so VS Code can attach
command: tail -f /dev/null
postgres:
image: docker.io/library/postgis/postgis:15-3.4
container_name: flyer-crawler-postgres
ports:
- '5432:5432'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: flyer_crawler_dev
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: docker.io/library/redis:alpine
container_name: flyer-crawler-redis
ports:
- '6379:6379'
volumes:
postgres_data:
node_modules_data:

4281
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,19 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"private": true, "private": true,
"version": "0.0.6", "version": "0.0.17",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"", "dev": "concurrently \"npm:start:dev\" \"vite\"",
"dev:container": "concurrently \"npm:start:dev\" \"vite --host\"",
"start": "npm run start:prod", "start": "npm run start:prod",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run", "test": "cross-env NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run",
"test-wsl": "cross-env NODE_ENV=test vitest run",
"test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage", "test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage",
"test:unit": "NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts", "test:unit": "NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
"test:integration": "NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts", "test:integration": "NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
@@ -20,6 +22,7 @@
"start:dev": "NODE_ENV=development tsx watch server.ts", "start:dev": "NODE_ENV=development tsx watch server.ts",
"start:prod": "NODE_ENV=production tsx server.ts", "start:prod": "NODE_ENV=production tsx server.ts",
"start:test": "NODE_ENV=test NODE_V8_COVERAGE=.coverage/tmp/integration-server tsx server.ts", "start:test": "NODE_ENV=test NODE_V8_COVERAGE=.coverage/tmp/integration-server tsx server.ts",
"db:reset:dev": "NODE_ENV=development tsx src/db/seed.ts",
"db:reset:test": "NODE_ENV=test tsx src/db/seed.ts", "db:reset:test": "NODE_ENV=test tsx src/db/seed.ts",
"worker:prod": "NODE_ENV=production tsx src/services/queueService.server.ts" "worker:prod": "NODE_ENV=production tsx src/services/queueService.server.ts"
}, },
@@ -95,6 +98,7 @@
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"c8": "^10.1.3", "c8": "^10.1.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",

View File

@@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS public.stores (
created_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL created_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
);
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).'; COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
-- 5. The 'categories' table for normalized category data. -- 5. The 'categories' table for normalized category data.

View File

@@ -1,4 +1,4 @@
// src/components/PriceHistoryChart.tsx // src/features/charts/PriceHistoryChart.tsx
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
LineChart, LineChart,
@@ -142,7 +142,7 @@ export const PriceHistoryChart: React.FC = () => {
const renderContent = () => { const renderContent = () => {
if (isLoading || isLoadingUserData) { if (isLoading || isLoadingUserData) {
return ( return (
<div role="status" className="flex justify-center items-center h-full min-h-[200px]"> <div role="status" className="flex justify-center items-center h-full min-h-50]">
<LoadingSpinner /> <span className="ml-2">Loading Price History...</span> <LoadingSpinner /> <span className="ml-2">Loading Price History...</span>
</div> </div>
); );
@@ -198,7 +198,12 @@ export const PriceHistoryChart: React.FC = () => {
borderRadius: '0.5rem', borderRadius: '0.5rem',
}} }}
labelStyle={{ color: '#F9FAFB' }} labelStyle={{ color: '#F9FAFB' }}
formatter={(value: number) => `$${(value / 100).toFixed(2)}`} formatter={(value: number | undefined) => {
if (typeof value === 'number') {
return [`$${(value / 100).toFixed(2)}`];
}
return [null];
}}
/> />
<Legend wrapperStyle={{ fontSize: '12px' }} /> <Legend wrapperStyle={{ fontSize: '12px' }} />
{availableItems.map((item, index) => ( {availableItems.map((item, index) => (

View File

@@ -2,8 +2,8 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useUserData } from '../hooks/useUserData'; import { useUserData } from './useUserData';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from './useAuth';
import { UserDataProvider } from '../providers/UserDataProvider'; import { UserDataProvider } from '../providers/UserDataProvider';
import { useApiOnMount } from './useApiOnMount'; import { useApiOnMount } from './useApiOnMount';
import type { UserProfile } from '../types'; import type { UserProfile } from '../types';

View File

@@ -2,7 +2,7 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer'; import multer from 'multer';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'node:fs';
import { z } from 'zod'; import { z } from 'zod';
import passport from './passport.routes'; import passport from './passport.routes';
import { optionalAuth } from './passport.routes'; import { optionalAuth } from './passport.routes';

View File

@@ -297,7 +297,6 @@ describe('Auth Routes (/api/auth)', () => {
// The API now returns a nested UserProfile object // The API now returns a nested UserProfile object
expect(response.body.userprofile).toEqual( expect(response.body.userprofile).toEqual(
expect.objectContaining({ expect.objectContaining({
user_id: 'user-123',
user: expect.objectContaining({ user: expect.objectContaining({
user_id: 'user-123', user_id: 'user-123',
email: loginCredentials.email, email: loginCredentials.email,

View File

@@ -1,38 +1,23 @@
// src/routes/system.routes.test.ts // src/routes/system.routes.test.ts
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import { exec, type ExecException, type ExecOptions } from 'child_process'; // Keep this for mocking import systemRouter from './system.routes'; // This was a duplicate, fixed.
import { exec, type ExecException, type ExecOptions } from 'child_process';
import { geocodingService } from '../services/geocodingService.server'; import { geocodingService } from '../services/geocodingService.server';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
// ============================================================================= // FIX: Use the simple factory pattern for child_process to avoid default export issues
// MOCKS CONFIGURATION
// =============================================================================
// 1. Mock child_process
// FIX: Use the simple factory pattern for child_process to avoid default export issues.
// We also add logging here to catch any un-mocked usages immediately.
vi.mock('child_process', () => { vi.mock('child_process', () => {
const defaultMockExec = (command: string, ...args: any[]) => { const mockExec = vi.fn((command, callback) => {
console.log(`[MOCK:child_process] Global exec hit for command: "${command}"`); if (typeof callback === 'function') {
const callback = args.find((arg) => typeof arg === 'function'); callback(null, 'PM2 OK', '');
if (callback) {
// Defensive: Run callback async to prevent Zalgo and stack overflows
process.nextTick(() => {
callback(null, 'PM2 OK', '');
});
} }
return { return { unref: () => {} };
unref: () => { });
/* no-op */
},
};
};
return { return {
default: { exec: defaultMockExec }, default: { exec: mockExec },
exec: defaultMockExec, exec: mockExec,
}; };
}); });
@@ -45,162 +30,205 @@ vi.mock('../services/geocodingService.server', () => ({
// 3. Mock Logger // 3. Mock Logger
vi.mock('../services/logger.server', () => ({ vi.mock('../services/logger.server', () => ({
logger: mockLogger, logger: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
child: vi.fn().mockReturnThis(),
},
})); }));
// Import the router AFTER all mocks are defined.
import systemRouter from './system.routes';
// =============================================================================
// TEST SUITE
// =============================================================================
describe('System Routes (/api/system)', () => { describe('System Routes (/api/system)', () => {
console.log('[TEST SUITE] initializing System Routes tests...');
const app = createTestApp({ router: systemRouter, basePath: '/api/system' }); const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
// Add a basic error handler to capture errors passed to next(err) and return JSON. // Add a basic error handler to capture errors passed to next(err) and return JSON.
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
console.error('[TEST SUITE] Error caught in test app error handler:', err.message);
res.status(err.status || 500).json({ message: err.message, errors: err.errors }); res.status(err.status || 500).json({ message: err.message, errors: err.errors });
}); });
beforeEach(() => { beforeEach(() => {
// We cast here to get type-safe access to mock functions like .mockImplementation // We cast here to get type-safe access to mock functions like .mockImplementation
vi.clearAllMocks(); vi.clearAllMocks();
console.log('[TEST SUITE] Mocks cleared.');
});
afterAll(() => {
vi.restoreAllMocks();
console.log('[TEST SUITE] Mocks restored. Suite finished.');
}); });
describe('GET /pm2-status', () => { describe('GET /pm2-status', () => {
// Helper function to set up the mock for `child_process.exec` for each test case. it('should return success: true when pm2 process is online', async () => {
// This avoids repeating the complex mock implementation in every test. // Arrange: Simulate a successful `pm2 describe` output for an online process.
const setupExecMock = (error: ExecException | null, stdout: string, stderr: string) => { const pm2OnlineOutput = `
console.log('[TEST SETUP] Configuring exec mock implementation'); ┌─ PM2 info ────────────────┐
│ status │ online │
└───────────┴───────────┘
`;
type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void;
// A robust mock for `exec` that handles its multiple overloads.
// This avoids the complex and error-prone `...args` signature.
vi.mocked(exec).mockImplementation(
(
command: string,
options?: ExecOptions | ExecCallback | null,
callback?: ExecCallback | null,
) => {
// The actual callback can be the second or third argument.
const actualCallback = (
typeof options === 'function' ? options : callback
) as ExecCallback;
if (actualCallback) {
actualCallback(null, pm2OnlineOutput, '');
}
// Return a minimal object that satisfies the ChildProcess type for .unref()
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act
const response = await supertest(app).get('/api/system/pm2-status');
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
message: 'Application is online and running under PM2.',
});
});
it('should return success: false when pm2 process is stopped or errored', async () => {
const pm2StoppedOutput = `│ status │ stopped │`;
vi.mocked(exec).mockImplementation( vi.mocked(exec).mockImplementation(
( (
command: string, command: string,
optionsOrCb?: options?:
| ExecOptions | ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void) | ((error: ExecException | null, stdout: string, stderr: string) => void)
| null, | null,
cb?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null, callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => { ) => {
console.log(`[MOCK EXEC] Command received: "${command}"`); const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
// Normalize arguments: options is optional stdout: string,
let callback: stderr: string,
| ((error: ExecException | null, stdout: string, stderr: string) => void) ) => void;
| undefined; if (actualCallback) {
actualCallback(null, pm2StoppedOutput, '');
if (typeof optionsOrCb === 'function') {
callback = optionsOrCb;
} else if (typeof cb === 'function') {
callback = cb;
} }
return { unref: () => {} } as ReturnType<typeof exec>;
if (callback) {
const safeCallback = callback;
// Defensive: Execute callback asynchronously (nextTick) to simulate real I/O
// This prevents the "synchronous callback" issue which can confuse test runners and async/await
process.nextTick(() => {
console.log(
`[MOCK EXEC] Invoking callback for "${command}" | Error: ${!!error} | Stdout len: ${stdout.length}`,
);
try {
safeCallback(error, stdout, stderr);
} catch (err) {
console.error('[MOCK EXEC] CRITICAL: Error inside exec callback:', err);
}
});
} else {
console.warn(`[MOCK EXEC] No callback provided for "${command}"`);
}
return {
unref: () => {
/* no-op */
},
} as ReturnType<typeof exec>;
}, },
); );
};
const testCases = [ const response = await supertest(app).get('/api/system/pm2-status');
{
description: 'should return success: true when pm2 process is online', // Assert
mock: { expect(response.status).toBe(200);
error: null, expect(response.body.success).toBe(false);
stdout: ` });
┌─ PM2 info ────────────────┐
│ status │ online │ it('should return success: false when pm2 process does not exist', async () => {
└───────────┴───────────┘ // Arrange: Simulate `pm2 describe` failing because the process isn't found.
`, const processNotFoundOutput =
stderr: '', "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
const processNotFoundError = new Error(
'Command failed: pm2 describe flyer-crawler-api',
) as ExecException;
processNotFoundError.code = 1;
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(processNotFoundError, processNotFoundOutput, '');
}
return { unref: () => {} } as ReturnType<typeof exec>;
}, },
expectedStatus: 200, );
expectedBody: { success: true, message: 'Application is online and running under PM2.' },
}, // Act
{ const response = await supertest(app).get('/api/system/pm2-status');
description: 'should return success: false when pm2 process is stopped',
mock: { error: null, stdout: '│ status │ stopped │', stderr: '' }, // Assert
expectedStatus: 200, expect(response.status).toBe(200);
expectedBody: { success: false, message: 'Application process exists but is not online.' }, expect(response.body).toEqual({
}, success: false,
{ message: 'Application process is not running under PM2.',
description: 'should return success: false when pm2 process does not exist', });
mock: { });
error: Object.assign(new Error('Command failed'), { code: 1 }),
stdout: "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist", it('should return 500 if pm2 command produces stderr output', async () => {
stderr: '', // Arrange: Simulate a successful exit code but with content in stderr.
const stderrOutput = 'A non-fatal warning occurred.';
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(null, 'Some stdout', stderrOutput);
}
return { unref: () => {} } as ReturnType<typeof exec>;
}, },
expectedStatus: 200, );
expectedBody: { success: false, message: 'Application process is not running under PM2.' },
},
{
description: 'should return 500 if pm2 command produces stderr output',
mock: { error: null, stdout: 'Some stdout', stderr: 'A non-fatal warning occurred.' },
expectedStatus: 500,
expectedBody: { message: 'PM2 command produced an error: A non-fatal warning occurred.' },
},
{
description: 'should return 500 on a generic exec error',
mock: { error: new Error('System error'), stdout: '', stderr: 'stderr output' },
expectedStatus: 500,
expectedBody: { message: 'System error' },
},
];
it.each(testCases)( const response = await supertest(app).get('/api/system/pm2-status');
'$description', expect(response.status).toBe(500);
async ({ mock, expectedStatus, expectedBody, description }) => { expect(response.body.message).toBe(`PM2 command produced an error: ${stderrOutput}`);
console.log(`[TEST CASE] Starting: ${description}`); });
// Arrange it('should return 500 on a generic exec error', async () => {
setupExecMock(mock.error as ExecException | null, mock.stdout, mock.stderr); vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(new Error('System error') as ExecException, '', 'stderr output');
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act // Act
console.log('[TEST CASE] Sending request...'); const response = await supertest(app).get('/api/system/pm2-status');
const response = await supertest(app).get('/api/system/pm2-status');
console.log(`[TEST CASE] Response received: status=${response.status}`);
// Assert // Assert
expect(response.status).toBe(expectedStatus); expect(response.status).toBe(500);
expect(response.body).toEqual(expectedBody); expect(response.body.message).toBe('System error');
console.log(`[TEST CASE] Completed: ${description}`); });
},
);
}); });
describe('POST /geocode', () => { describe('POST /geocode', () => {
it('should return geocoded coordinates for a valid address', async () => { it('should return geocoded coordinates for a valid address', async () => {
console.log('[TEST CASE] POST /geocode - valid address');
// Arrange // Arrange
const mockCoordinates = { lat: 48.4284, lng: -123.3656 }; const mockCoordinates = { lat: 48.4284, lng: -123.3656 };
vi.mocked(geocodingService.geocodeAddress).mockResolvedValue(mockCoordinates); vi.mocked(geocodingService.geocodeAddress).mockResolvedValue(mockCoordinates);
@@ -216,7 +244,6 @@ describe('System Routes (/api/system)', () => {
}); });
it('should return 404 if the address cannot be geocoded', async () => { it('should return 404 if the address cannot be geocoded', async () => {
console.log('[TEST CASE] POST /geocode - address not found');
vi.mocked(geocodingService.geocodeAddress).mockResolvedValue(null); vi.mocked(geocodingService.geocodeAddress).mockResolvedValue(null);
const response = await supertest(app) const response = await supertest(app)
.post('/api/system/geocode') .post('/api/system/geocode')
@@ -226,7 +253,6 @@ describe('System Routes (/api/system)', () => {
}); });
it('should return 500 if the geocoding service throws an error', async () => { it('should return 500 if the geocoding service throws an error', async () => {
console.log('[TEST CASE] POST /geocode - service error');
const geocodeError = new Error('Geocoding service unavailable'); const geocodeError = new Error('Geocoding service unavailable');
vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(geocodeError); vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(geocodeError);
const response = await supertest(app) const response = await supertest(app)
@@ -236,7 +262,6 @@ describe('System Routes (/api/system)', () => {
}); });
it('should return 400 if the address is missing from the body', async () => { it('should return 400 if the address is missing from the body', async () => {
console.log('[TEST CASE] POST /geocode - validation error');
const response = await supertest(app) const response = await supertest(app)
.post('/api/system/geocode') .post('/api/system/geocode')
.send({ not_address: 'Victoria, BC' }); .send({ not_address: 'Victoria, BC' });

View File

@@ -1,4 +1,4 @@
// src/routes/system.ts // src/routes/system.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { z } from 'zod'; import { z } from 'zod';
@@ -29,24 +29,12 @@ router.get(
'/pm2-status', '/pm2-status',
validateRequest(emptySchema), validateRequest(emptySchema),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
// DEBUG: Add logging to trace entry into the route
console.log(`[API /pm2-status] Received request. Executing PM2 check...`);
// The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file. // The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file.
exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => { exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => {
// DEBUG: Log callback entry
console.log(
`[API /pm2-status] PM2 check complete. Error: ${!!error}, Stderr Len: ${stderr?.length}`,
);
// The "doesn't exist" message can appear in stdout or stderr depending on PM2 version and context.
const processNotFound =
stdout?.includes("doesn't exist") || stderr?.includes("doesn't exist");
if (error) { if (error) {
// 'pm2 describe' exits with an error if the process is not found. // 'pm2 describe' exits with an error if the process is not found.
// We can treat this as a "fail" status for our check. // We can treat this as a "fail" status for our check.
if (processNotFound) { if (stdout && stdout.includes("doesn't exist")) {
logger.warn('[API /pm2-status] PM2 process "flyer-crawler-api" not found.'); logger.warn('[API /pm2-status] PM2 process "flyer-crawler-api" not found.');
return res.json({ return res.json({
success: false, success: false,
@@ -57,8 +45,6 @@ router.get(
{ error: stderr || error.message }, { error: stderr || error.message },
'[API /pm2-status] Error executing pm2 describe:', '[API /pm2-status] Error executing pm2 describe:',
); );
// DEBUG: Explicit log for test debugging
console.error('[API /pm2-status] Exec failed:', error);
return next(error); return next(error);
} }
@@ -66,7 +52,6 @@ router.get(
// This handles warnings or non-fatal errors that should arguably be treated as failures in this context. // This handles warnings or non-fatal errors that should arguably be treated as failures in this context.
if (stderr && stderr.trim().length > 0) { if (stderr && stderr.trim().length > 0) {
logger.error({ stderr }, '[API /pm2-status] PM2 executed but produced stderr:'); logger.error({ stderr }, '[API /pm2-status] PM2 executed but produced stderr:');
console.error('[API /pm2-status] STDERR present:', stderr);
return next(new Error(`PM2 command produced an error: ${stderr}`)); return next(new Error(`PM2 command produced an error: ${stderr}`));
} }
@@ -75,8 +60,6 @@ router.get(
const message = isOnline const message = isOnline
? 'Application is online and running under PM2.' ? 'Application is online and running under PM2.'
: 'Application process exists but is not online.'; : 'Application process exists but is not online.';
console.log(`[API /pm2-status] Success. Online: ${isOnline}`);
res.json({ success: isOnline, message }); res.json({ success: isOnline, message });
}); });
}, },
@@ -96,22 +79,16 @@ router.post(
body: { address }, body: { address },
} = req as unknown as GeocodeRequest; } = req as unknown as GeocodeRequest;
// DEBUG
console.log(`[API /geocode] Request for address: "${address}"`);
try { try {
const coordinates = await geocodingService.geocodeAddress(address, req.log); const coordinates = await geocodingService.geocodeAddress(address, req.log);
if (!coordinates) { if (!coordinates) {
console.log('[API /geocode] Address not found.');
// This check remains, but now it only fails if BOTH services fail. // This check remains, but now it only fails if BOTH services fail.
return res.status(404).json({ message: 'Could not geocode the provided address.' }); return res.status(404).json({ message: 'Could not geocode the provided address.' });
} }
console.log('[API /geocode] Success:', coordinates);
res.json(coordinates); res.json(coordinates);
} catch (error) { } catch (error) {
console.error('[API /geocode] Error:', error);
next(error); next(error);
} }
}, },

View File

@@ -52,10 +52,7 @@ export class UserRepository {
); );
return res.rows[0]; return res.rows[0];
} catch (error) { } catch (error) {
logger.error( logger.error({ err: error, email }, 'Database error in findUserByEmail');
{ err: error instanceof Error ? error.message : error, email },
'Database error in findUserByEmail',
);
throw new Error('Failed to retrieve user from database.'); throw new Error('Failed to retrieve user from database.');
} }
} }
@@ -130,10 +127,7 @@ export class UserRepository {
throw new UniqueConstraintError('A user with this email address already exists.'); throw new UniqueConstraintError('A user with this email address already exists.');
} }
// The withTransaction helper logs the rollback, so we just log the context here. // The withTransaction helper logs the rollback, so we just log the context here.
logger.error( logger.error({ err: error, email }, 'Error during createUser transaction');
{ err: error instanceof Error ? error.message : error, email },
'Error during createUser transaction',
);
throw new Error('Failed to create user in database.'); throw new Error('Failed to create user in database.');
}); });
} }
@@ -188,10 +182,7 @@ export class UserRepository {
return authableProfile; return authableProfile;
} catch (error) { } catch (error) {
logger.error( logger.error({ err: error, email }, 'Database error in findUserWithProfileByEmail');
{ err: error instanceof Error ? error.message : error, email },
'Database error in findUserWithProfileByEmail',
);
throw new Error('Failed to retrieve user with profile from database.'); throw new Error('Failed to retrieve user with profile from database.');
} }
} }
@@ -215,7 +206,7 @@ export class UserRepository {
} catch (error) { } catch (error) {
if (error instanceof NotFoundError) throw error; if (error instanceof NotFoundError) throw error;
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in findUserById', 'Database error in findUserById',
); );
throw new Error('Failed to retrieve user by ID from database.'); throw new Error('Failed to retrieve user by ID from database.');
@@ -242,7 +233,7 @@ export class UserRepository {
} catch (error) { } catch (error) {
if (error instanceof NotFoundError) throw error; if (error instanceof NotFoundError) throw error;
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in findUserWithPasswordHashById', 'Database error in findUserWithPasswordHashById',
); );
throw new Error('Failed to retrieve user with sensitive data by ID from database.'); throw new Error('Failed to retrieve user with sensitive data by ID from database.');
@@ -291,7 +282,7 @@ export class UserRepository {
throw error; throw error;
} }
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in findUserProfileById', 'Database error in findUserProfileById',
); );
throw new Error('Failed to retrieve user profile from database.'); throw new Error('Failed to retrieve user profile from database.');
@@ -340,7 +331,7 @@ export class UserRepository {
throw error; throw error;
} }
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId, profileData }, { err: error, userId, profileData },
'Database error in updateUserProfile', 'Database error in updateUserProfile',
); );
throw new Error('Failed to update user profile in database.'); throw new Error('Failed to update user profile in database.');
@@ -372,7 +363,7 @@ export class UserRepository {
throw error; throw error;
} }
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId, preferences }, { err: error, userId, preferences },
'Database error in updateUserPreferences', 'Database error in updateUserPreferences',
); );
throw new Error('Failed to update user preferences in database.'); throw new Error('Failed to update user preferences in database.');
@@ -393,7 +384,7 @@ export class UserRepository {
); );
} catch (error) { } catch (error) {
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in updateUserPassword', 'Database error in updateUserPassword',
); );
throw new Error('Failed to update user password in database.'); throw new Error('Failed to update user password in database.');
@@ -408,9 +399,9 @@ export class UserRepository {
async deleteUserById(userId: string, logger: Logger): Promise<void> { async deleteUserById(userId: string, logger: Logger): Promise<void> {
try { try {
await this.db.query('DELETE FROM public.users WHERE user_id = $1', [userId]); await this.db.query('DELETE FROM public.users WHERE user_id = $1', [userId]);
} catch (error) { } catch (error) { // This was a duplicate, fixed.
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in deleteUserById', 'Database error in deleteUserById',
); );
throw new Error('Failed to delete user from database.'); throw new Error('Failed to delete user from database.');
@@ -431,7 +422,7 @@ export class UserRepository {
); );
} catch (error) { } catch (error) {
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in saveRefreshToken', 'Database error in saveRefreshToken',
); );
throw new Error('Failed to save refresh token.'); throw new Error('Failed to save refresh token.');
@@ -457,7 +448,7 @@ export class UserRepository {
} catch (error) { } catch (error) {
if (error instanceof NotFoundError) throw error; if (error instanceof NotFoundError) throw error;
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error }, { err: error },
'Database error in findUserByRefreshToken', 'Database error in findUserByRefreshToken',
); );
throw new Error('Failed to find user by refresh token.'); // Generic error for other failures throw new Error('Failed to find user by refresh token.'); // Generic error for other failures
@@ -474,10 +465,7 @@ export class UserRepository {
refreshToken, refreshToken,
]); ]);
} catch (error) { } catch (error) {
logger.error( logger.error({ err: error }, 'Database error in deleteRefreshToken');
{ err: error instanceof Error ? error.message : error },
'Database error in deleteRefreshToken',
);
} }
} }
@@ -501,7 +489,7 @@ export class UserRepository {
throw new ForeignKeyConstraintError('The specified user does not exist.'); throw new ForeignKeyConstraintError('The specified user does not exist.');
} }
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in createPasswordResetToken', 'Database error in createPasswordResetToken',
); );
throw new Error('Failed to create password reset token.'); throw new Error('Failed to create password reset token.');
@@ -521,7 +509,7 @@ export class UserRepository {
return res.rows; return res.rows;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error }, { err: error },
'Database error in getValidResetTokens', 'Database error in getValidResetTokens',
); );
throw new Error('Failed to retrieve valid reset tokens.'); throw new Error('Failed to retrieve valid reset tokens.');
@@ -538,7 +526,7 @@ export class UserRepository {
await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]); await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, tokenHash }, { err: error, tokenHash },
'Database error in deleteResetToken', 'Database error in deleteResetToken',
); );
} }
@@ -559,10 +547,7 @@ export class UserRepository {
); );
return res.rowCount ?? 0; return res.rowCount ?? 0;
} catch (error) { } catch (error) {
logger.error( logger.error({ err: error }, 'Database error in deleteExpiredResetTokens');
{ err: error instanceof Error ? error.message : error },
'Database error in deleteExpiredResetTokens',
);
throw new Error('Failed to delete expired password reset tokens.'); throw new Error('Failed to delete expired password reset tokens.');
} }
} }
@@ -581,10 +566,7 @@ export class UserRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') { if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or both users do not exist.'); throw new ForeignKeyConstraintError('One or both users do not exist.');
} }
logger.error( logger.error({ err: error, followerId, followingId }, 'Database error in followUser');
{ err: error instanceof Error ? error.message : error, followerId, followingId },
'Database error in followUser',
);
throw new Error('Failed to follow user.'); throw new Error('Failed to follow user.');
} }
} }
@@ -601,10 +583,7 @@ export class UserRepository {
[followerId, followingId], [followerId, followingId],
); );
} catch (error) { } catch (error) {
logger.error( logger.error({ err: error, followerId, followingId }, 'Database error in unfollowUser');
{ err: error instanceof Error ? error.message : error, followerId, followingId },
'Database error in unfollowUser',
);
throw new Error('Failed to unfollow user.'); throw new Error('Failed to unfollow user.');
} }
} }
@@ -635,10 +614,7 @@ export class UserRepository {
const res = await this.db.query<ActivityLogItem>(query, [userId, limit, offset]); const res = await this.db.query<ActivityLogItem>(query, [userId, limit, offset]);
return res.rows; return res.rows;
} catch (error) { } catch (error) {
logger.error( logger.error({ err: error, userId, limit, offset }, 'Database error in getUserFeed');
{ err: error instanceof Error ? error.message : error, userId, limit, offset },
'Database error in getUserFeed',
);
throw new Error('Failed to retrieve user feed.'); throw new Error('Failed to retrieve user feed.');
} }
} }
@@ -660,10 +636,7 @@ export class UserRepository {
); );
return res.rows[0]; return res.rows[0];
} catch (error) { } catch (error) {
logger.error( logger.error({ err: error, queryData }, 'Database error in logSearchQuery');
{ err: error instanceof Error ? error.message : error, queryData },
'Database error in logSearchQuery',
);
throw new Error('Failed to log search query.'); throw new Error('Failed to log search query.');
} }
} }
@@ -698,7 +671,7 @@ export async function exportUserData(userId: string, logger: Logger): Promise<{
}); });
} catch (error) { } catch (error) {
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, userId }, { err: error, userId },
'Database error in exportUserData', 'Database error in exportUserData',
); );
throw new Error('Failed to export user data.'); throw new Error('Failed to export user data.');

View File

@@ -45,7 +45,7 @@ export class GoogleGeocodingService {
return null; return null;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ err: error instanceof Error ? error.message : error, address }, { err: error, address },
'[GoogleGeocodingService] An error occurred while calling the Google Maps API.', '[GoogleGeocodingService] An error occurred while calling the Google Maps API.',
); );
throw error; // Re-throw to allow the calling service to handle the failure (e.g., by falling back). throw error; // Re-throw to allow the calling service to handle the failure (e.g., by falling back).

View File

@@ -1,4 +1,5 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
// vitest.config.ts
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
@@ -6,12 +7,11 @@ export default defineConfig({
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
// This setup file is where we can add global test configurations // This setup file is where we can add global test configurations
setupFiles: [ setupFiles: ['./src/tests/setup/tests-setup-unit.ts'],
'./src/tests/setup/tests-setup-unit.ts', // , './src/tests/setup/mockHooks.ts'
'./src/tests/setup/mockHooks.ts', // removed this from above: './src/tests/setup/mockComponents.tsx'
'./src/tests/setup/mockComponents.tsx'
],
// This line is the key fix: it tells Vitest to include the type definitions // This line is the key fix: it tells Vitest to include the type definitions
include: ['src/**/*.test.tsx'], include: ['src/**/*.test.{ts,tsx}'],
}, },
}); });