Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5274650aea | ||
| de5a9a565b | |||
| 10a379c5e3 | |||
| a6a484d432 | |||
|
|
4b0a172c35 | ||
| e8c894d5cf | |||
| 6c8fd4b126 | |||
|
|
a1f52544d0 | ||
| 2334359756 | |||
| 406954ca06 | |||
|
|
95d441be98 | ||
| 186ed484b7 | |||
|
|
3669958e9d | ||
| 5f3daf0539 | |||
| ae7afaaf97 | |||
|
|
3ae7b9e0d4 | ||
| 921c48fc57 | |||
|
|
2571864b91 | ||
| 065d0c746a | |||
| 395f6c21a2 | |||
|
|
aec56dfc23 | ||
| a12a0e5207 | |||
| e337bd67b1 | |||
|
|
a8f5b4e51a | ||
| d0ce8021d6 | |||
| efbb162880 |
18
.devcontainer/devcontainer.json
Normal file
18
.devcontainer/devcontainer.json
Normal 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\""
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
DB_USER: ${{ secrets.DB_USER }}
|
DB_USER: ${{ secrets.DB_USER }}
|
||||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||||
BACKUP_DIR: "/var/www/backups" # Define a dedicated directory for backups
|
BACKUP_DIR: '/var/www/backups' # Define a dedicated directory for backups
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Validate Secrets and Inputs
|
- name: Validate Secrets and Inputs
|
||||||
|
|||||||
31
Dockerfile.dev
Normal file
31
Dockerfile.dev
Normal 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"]
|
||||||
80
README.md
80
README.md
@@ -95,7 +95,7 @@ actually the proper change was to do this in the /etc/nginx/sites-available/flye
|
|||||||
## for OAuth
|
## for OAuth
|
||||||
|
|
||||||
1. Get Google OAuth Credentials
|
1. Get Google OAuth Credentials
|
||||||
This is a crucial step that you must do outside the codebase:
|
This is a crucial step that you must do outside the codebase:
|
||||||
|
|
||||||
Go to the Google Cloud Console.
|
Go to the Google Cloud Console.
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Under Authorized redirect URIs, click ADD URI and enter the URL where Google wil
|
|||||||
Click Create. You will be given a Client ID and a Client Secret.
|
Click Create. You will be given a Client ID and a Client Secret.
|
||||||
|
|
||||||
2. Get GitHub OAuth Credentials
|
2. Get GitHub OAuth Credentials
|
||||||
You'll need to obtain a Client ID and Client Secret from GitHub:
|
You'll need to obtain a Client ID and Client Secret from GitHub:
|
||||||
|
|
||||||
Go to your GitHub profile settings.
|
Go to your GitHub profile settings.
|
||||||
|
|
||||||
@@ -133,21 +133,23 @@ You will be given a Client ID and a Client Secret.
|
|||||||
|
|
||||||
psql -h localhost -U flyer_crawler_user -d "flyer-crawler-prod" -W
|
psql -h localhost -U flyer_crawler_user -d "flyer-crawler-prod" -W
|
||||||
|
|
||||||
|
|
||||||
## postgis
|
## postgis
|
||||||
|
|
||||||
flyer-crawler-prod=> SELECT version();
|
flyer-crawler-prod=> SELECT version();
|
||||||
version
|
version
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------
|
|
||||||
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0, 64-bit
|
---
|
||||||
|
|
||||||
|
PostgreSQL 14.19 (Ubuntu 14.19-0ubuntu0.22.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0, 64-bit
|
||||||
(1 row)
|
(1 row)
|
||||||
|
|
||||||
flyer-crawler-prod=> SELECT PostGIS_Full_Version();
|
flyer-crawler-prod=> SELECT PostGIS_Full_Version();
|
||||||
postgis_full_version
|
postgis_full_version
|
||||||
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
||||||
POSTGIS="3.2.0 c3e3cc0" [EXTENSION] PGSQL="140" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1" LIBXML="2.9.12" LIBJSON="0.15" LIBPROTOBUF="1.3.3" WAGYU="0.5.0 (Internal)"
|
|
||||||
(1 row)
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
POSTGIS="3.2.0 c3e3cc0" [EXTENSION] PGSQL="140" GEOS="3.10.2-CAPI-1.16.0" PROJ="8.2.1" LIBXML="2.9.12" LIBJSON="0.15" LIBPROTOBUF="1.3.3" WAGYU="0.5.0 (Internal)"
|
||||||
|
(1 row)
|
||||||
|
|
||||||
## production postgres setup
|
## production postgres setup
|
||||||
|
|
||||||
@@ -201,9 +203,13 @@ Step 4: Seed the Admin Account (If Needed)
|
|||||||
Your application has a separate script to create the initial admin user. To run it, you must first set the required environment variables in your shell session.
|
Your application has a separate script to create the initial admin user. To run it, you must first set the required environment variables in your shell session.
|
||||||
|
|
||||||
bash
|
bash
|
||||||
|
|
||||||
# Set variables for the current session
|
# Set variables for the current session
|
||||||
|
|
||||||
export DB_USER=flyer_crawler_user DB_PASSWORD=your_password DB_NAME="flyer-crawler-prod" ...
|
export DB_USER=flyer_crawler_user DB_PASSWORD=your_password DB_NAME="flyer-crawler-prod" ...
|
||||||
|
|
||||||
# Run the seeding script
|
# Run the seeding script
|
||||||
|
|
||||||
npx tsx src/db/seed_admin_account.ts
|
npx tsx src/db/seed_admin_account.ts
|
||||||
Your production database is now ready!
|
Your production database is now ready!
|
||||||
|
|
||||||
@@ -284,8 +290,6 @@ Test Execution: Your tests run against this clean, isolated schema.
|
|||||||
|
|
||||||
This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline.
|
This approach is faster, more reliable, and removes the need for sudo access within the CI pipeline.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
||||||
[PM2][Module] Installing NPM pm2-logrotate module
|
[PM2][Module] Installing NPM pm2-logrotate module
|
||||||
[PM2][Module] Calling [NPM] to install pm2-logrotate ...
|
[PM2][Module] Calling [NPM] to install pm2-logrotate ...
|
||||||
@@ -293,7 +297,7 @@ gitea-runner@projectium:~$ pm2 install pm2-logrotate
|
|||||||
added 161 packages in 5s
|
added 161 packages in 5s
|
||||||
|
|
||||||
21 packages are looking for funding
|
21 packages are looking for funding
|
||||||
run `npm fund` for details
|
run `npm fund` for details
|
||||||
npm notice
|
npm notice
|
||||||
npm notice New patch version of npm available! 11.6.3 -> 11.6.4
|
npm notice New patch version of npm available! 11.6.3 -> 11.6.4
|
||||||
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.4
|
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.4
|
||||||
@@ -308,7 +312,7 @@ $ pm2 set pm2-logrotate:retain 30
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 \* \* _
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
Modules configuration. Copy/Paste line to edit values.
|
Modules configuration. Copy/Paste line to edit values.
|
||||||
[PM2][Module] Module successfully installed and launched
|
[PM2][Module] Module successfully installed and launched
|
||||||
@@ -335,7 +339,7 @@ $ pm2 set pm2-logrotate:retain 30
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 _ \* _
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
gitea-runner@projectium:~$ pm2 set pm2-logrotate:retain 14
|
gitea-runner@projectium:~$ pm2 set pm2-logrotate:retain 14
|
||||||
[PM2] Module pm2-logrotate restarted
|
[PM2] Module pm2-logrotate restarted
|
||||||
@@ -346,31 +350,29 @@ $ pm2 set pm2-logrotate:retain 14
|
|||||||
$ pm2 set pm2-logrotate:compress false
|
$ pm2 set pm2-logrotate:compress false
|
||||||
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
|
||||||
$ pm2 set pm2-logrotate:workerInterval 30
|
$ pm2 set pm2-logrotate:workerInterval 30
|
||||||
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
|
$ pm2 set pm2-logrotate:rotateInterval 0 0 _ \* \*
|
||||||
$ pm2 set pm2-logrotate:rotateModule true
|
$ pm2 set pm2-logrotate:rotateModule true
|
||||||
gitea-runner@projectium:~$
|
gitea-runner@projectium:~$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## dev server setup:
|
## dev server setup:
|
||||||
|
|
||||||
Here are the steps to set up the development environment on Windows using Podman with an Ubuntu container:
|
Here are the steps to set up the development environment on Windows using Podman with an Ubuntu container:
|
||||||
|
|
||||||
1. Install Prerequisites on Windows
|
1. Install Prerequisites on Windows
|
||||||
Install WSL 2: Podman on Windows relies on the Windows Subsystem for Linux. Install it by running wsl --install in an administrator PowerShell.
|
Install WSL 2: Podman on Windows relies on the Windows Subsystem for Linux. Install it by running wsl --install in an administrator PowerShell.
|
||||||
Install Podman Desktop: Download and install Podman Desktop for Windows.
|
Install Podman Desktop: Download and install Podman Desktop for Windows.
|
||||||
|
|
||||||
2. Set Up Podman
|
2. Set Up Podman
|
||||||
Initialize Podman: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
Initialize Podman: Launch Podman Desktop. It will automatically set up its WSL 2 machine.
|
||||||
Start Podman: Ensure the Podman machine is running from the Podman Desktop interface.
|
Start Podman: Ensure the Podman machine is running from the Podman Desktop interface.
|
||||||
|
|
||||||
3. Set Up the Ubuntu Container
|
3. Set Up the Ubuntu Container
|
||||||
- Pull Ubuntu Image: Open a PowerShell or command prompt and pull the latest Ubuntu image:
|
|
||||||
|
- Pull Ubuntu Image: Open a PowerShell or command prompt and pull the latest Ubuntu image:
|
||||||
podman pull ubuntu:latest
|
podman pull ubuntu:latest
|
||||||
- Create a Podman Volume: Create a volume to persist node_modules and avoid installing them every time the container starts.
|
- Create a Podman Volume: Create a volume to persist node_modules and avoid installing them every time the container starts.
|
||||||
podman volume create node_modules_cache
|
podman volume create node_modules_cache
|
||||||
- Run the Ubuntu Container: Start a new container with the project directory mounted and the necessary ports forwarded.
|
- Run the Ubuntu Container: Start a new container with the project directory mounted and the necessary ports forwarded.
|
||||||
- Open a terminal in your project's root directory on Windows.
|
- Open a terminal in your project's root directory on Windows.
|
||||||
- Run the following command, replacing D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com with the full path to your project:
|
- Run the following command, replacing D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com with the full path to your project:
|
||||||
|
|
||||||
@@ -385,16 +387,16 @@ podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-cra
|
|||||||
4. Configure the Ubuntu Environment
|
4. Configure the Ubuntu Environment
|
||||||
You are now inside the Ubuntu container's shell.
|
You are now inside the Ubuntu container's shell.
|
||||||
|
|
||||||
- Update Package Lists:
|
- Update Package Lists:
|
||||||
apt-get update
|
apt-get update
|
||||||
- Install Dependencies: Install curl, git, and nodejs (which includes npm).
|
- Install Dependencies: Install curl, git, and nodejs (which includes npm).
|
||||||
apt-get install -y curl git
|
apt-get install -y curl git
|
||||||
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
apt-get install -y nodejs
|
apt-get install -y nodejs
|
||||||
- Navigate to Project Directory:
|
- Navigate to Project Directory:
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
- Install Project Dependencies:
|
- Install Project Dependencies:
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
5. Run the Development Server
|
5. Run the Development Server
|
||||||
@@ -402,27 +404,21 @@ podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "D:\gitea\flyer-cra
|
|||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
6. Accessing the Application
|
6. Accessing the Application
|
||||||
- Frontend: Open your browser and go to http://localhost:5173.
|
|
||||||
- Backend: The frontend will make API calls to http://localhost:3001.
|
- Frontend: Open your browser and go to http://localhost:5173.
|
||||||
|
- Backend: The frontend will make API calls to http://localhost:3001.
|
||||||
|
|
||||||
Managing the Environment
|
Managing the Environment
|
||||||
- Stopping the Container: Press Ctrl+C in the container terminal, then type exit.
|
|
||||||
- Restarting the Container:
|
- Stopping the Container: Press Ctrl+C in the container terminal, then type exit.
|
||||||
|
- Restarting the Container:
|
||||||
podman start -a -i flyer-dev
|
podman start -a -i flyer-dev
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## for me:
|
## for me:
|
||||||
|
|
||||||
cd /mnt/d/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com
|
cd /mnt/d/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com
|
||||||
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "$(pwd):/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
podman run -it -p 3001:3001 -p 5173:5173 --name flyer-dev -v "$(pwd):/app" -v "node_modules_cache:/app/node_modules" ubuntu:latest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
rate limiting
|
rate limiting
|
||||||
|
|
||||||
respect the AI service's rate limits, making it more stable and robust. You can adjust the GEMINI_RPM environment variable in your production environment as needed without changing the code.
|
respect the AI service's rate limits, making it more stable and robust. You can adjust the GEMINI_RPM environment variable in your production environment as needed without changing the code.
|
||||||
52
compose.dev.yml
Normal file
52
compose.dev.yml
Normal 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:
|
||||||
@@ -34,7 +34,7 @@ We will adopt a strict, consistent error-handling contract for the service and r
|
|||||||
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
|
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
|
||||||
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
|
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
|
||||||
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
|
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
|
||||||
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the *exact* error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the _exact_ error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
|
||||||
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
|
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
|
||||||
|
|
||||||
### Negative
|
### Negative
|
||||||
|
|||||||
@@ -10,21 +10,19 @@ Following the standardization of error handling in ADR-001, the next most common
|
|||||||
|
|
||||||
This manual approach has several drawbacks:
|
This manual approach has several drawbacks:
|
||||||
**Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files.
|
**Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files.
|
||||||
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application.
|
**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application. 3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
||||||
3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management.
|
We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management.
|
||||||
|
|
||||||
1. **`withTransaction` Helper**: A new helper function, `withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`, will be created. This function will be responsible for:
|
1. **`withTransaction` Helper**: A new helper function, `withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`, will be created. This function will be responsible for:
|
||||||
|
- Acquiring a client from the database pool.
|
||||||
* Acquiring a client from the database pool.
|
- Starting a transaction (`BEGIN`).
|
||||||
* Starting a transaction (`BEGIN`).
|
- Executing the `callback` function, passing the transactional client to it.
|
||||||
* Executing the `callback` function, passing the transactional client to it.
|
- If the callback succeeds, it will `COMMIT` the transaction.
|
||||||
* If the callback succeeds, it will `COMMIT` the transaction.
|
- If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
||||||
* If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error.
|
- In all cases, it will `RELEASE` the client back to the pool.
|
||||||
* In all cases, it will `RELEASE` the client back to the pool.
|
|
||||||
|
|
||||||
2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client.
|
2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client.
|
||||||
3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
|
3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
|
||||||
@@ -40,7 +38,7 @@ async function registerUserAndCreateDefaultList(userData) {
|
|||||||
const shoppingRepo = new ShoppingRepository(client);
|
const shoppingRepo = new ShoppingRepository(client);
|
||||||
|
|
||||||
const newUser = await userRepo.createUser(userData);
|
const newUser = await userRepo.createUser(userData);
|
||||||
await shoppingRepo.createShoppingList(newUser.user_id, "My First List");
|
await shoppingRepo.createShoppingList(newUser.user_id, 'My First List');
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ We will adopt a schema-based approach for input validation using the `zod` libra
|
|||||||
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
||||||
|
|
||||||
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
||||||
* On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
- On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
||||||
* On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
- On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
||||||
|
|
||||||
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ const getFlyerSchema = z.object({
|
|||||||
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
||||||
|
|
||||||
// 3. Apply the middleware and use an inline cast for the request
|
// 3. Apply the middleware and use an inline cast for the request
|
||||||
router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => {
|
router.get('/:id', validateRequest(getFlyerSchema), async (req, res, next) => {
|
||||||
// Cast 'req' to the inferred type.
|
// Cast 'req' to the inferred type.
|
||||||
// This provides full type safety for params, query, and body.
|
// This provides full type safety for params, query, and body.
|
||||||
const { params } = req as unknown as GetFlyerRequest;
|
const { params } = req as unknown as GetFlyerRequest;
|
||||||
@@ -57,7 +57,7 @@ router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}));
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
|||||||
|
|
||||||
**Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
**Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will:
|
||||||
|
|
||||||
* Generate a unique `request_id` for each incoming request.
|
- Generate a unique `request_id` for each incoming request.
|
||||||
* Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
- Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates.
|
||||||
* Attach this child logger to the `req` object (e.g., `req.log`).
|
- Attach this child logger to the `req` object (e.g., `req.log`).
|
||||||
|
|
||||||
**Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
**Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated.
|
||||||
|
|
||||||
@@ -32,9 +32,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo
|
|||||||
|
|
||||||
**Standardized Logging Practices**:
|
**Standardized Logging Practices**:
|
||||||
**INFO**: Log key business events, such as `User logged in` or `Flyer processed`.
|
**INFO**: Log key business events, such as `User logged in` or `Flyer processed`.
|
||||||
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`.
|
||||||
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level.
|
||||||
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states.
|
||||||
|
|
||||||
### Example Usage
|
### Example Usage
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will formalize a centralized Role-Based Access Control (RBAC) or Attribute-Ba
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
- **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control.
|
||||||
* **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
- **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will establish a formal Design System and Component Library. This will involv
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
- **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
|
||||||
* **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
- **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will adopt a dedicated database migration tool, such as **`node-pg-migrate`**
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
- **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments.
|
||||||
* **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
- **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will standardize the deployment process by containerizing the application usi
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application.
|
- **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application.
|
||||||
* **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration.
|
- **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration.
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ We will implement a multi-layered security approach for the API:
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
- **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks.
|
||||||
* **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
- **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will formalize the end-to-end CI/CD process. This ADR will define the project
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
- **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors.
|
||||||
* **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
- **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will adopt **OpenAPI (Swagger)** for API documentation. We will use tools (e.
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing.
|
- **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing.
|
||||||
* **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project.
|
- **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project.
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ We will implement a formal data backup and recovery strategy. This will involve
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
- **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
|
||||||
* **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
- **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ When the application is containerized (`ADR-014`), the container orchestrator (e
|
|||||||
|
|
||||||
We will implement dedicated health check endpoints in the Express application.
|
We will implement dedicated health check endpoints in the Express application.
|
||||||
|
|
||||||
* A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
- A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container.
|
||||||
|
|
||||||
* A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
- A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
* **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
- **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances.
|
||||||
* **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
- **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer.
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ We will adopt a standardized, application-wide structured logging policy for all
|
|||||||
|
|
||||||
**2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
**2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures:
|
||||||
|
|
||||||
* `logger.info('A simple message');`
|
- `logger.info('A simple message');`
|
||||||
* `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
- `logger.info({ key: 'value' }, 'A message with a structured data payload');`
|
||||||
|
|
||||||
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state.
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ describe('MyComponent', () => {
|
|||||||
// Assert that the logger was called with the expected structure
|
// Assert that the logger was called with the expected structure
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ err: expect.any(Error) }), // Check for the error object
|
expect.objectContaining({ err: expect.any(Error) }), // Check for the error object
|
||||||
'Failed to fetch component data' // Check for the message
|
'Failed to fetch component data', // Check for the message
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import globals from "globals";
|
import globals from 'globals';
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from 'typescript-eslint';
|
||||||
import pluginReact from "eslint-plugin-react";
|
import pluginReact from 'eslint-plugin-react';
|
||||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||||
import pluginReactRefresh from "eslint-plugin-react-refresh";
|
import pluginReactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
// Global ignores
|
// Global ignores
|
||||||
ignores: ["dist", ".gitea", "node_modules", "*.cjs"],
|
ignores: ['dist', '.gitea', 'node_modules', '*.cjs'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// All files
|
// All files
|
||||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
|
||||||
plugins: {
|
plugins: {
|
||||||
react: pluginReact,
|
react: pluginReact,
|
||||||
"react-hooks": pluginReactHooks,
|
'react-hooks': pluginReactHooks,
|
||||||
"react-refresh": pluginReactRefresh,
|
'react-refresh': pluginReactRefresh,
|
||||||
},
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
@@ -24,10 +24,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"react-refresh/only-export-components": [
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
"warn",
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// TypeScript files
|
// TypeScript files
|
||||||
|
|||||||
2
express.d.ts
vendored
2
express.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
// src/types/express.d.ts
|
// express.d.ts
|
||||||
import { Logger } from 'pino';
|
import { Logger } from 'pino';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
14
index.html
14
index.html
@@ -1,8 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Grocery Flyer AI Analyzer</title>
|
<title>Grocery Flyer AI Analyzer</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- The stylesheet will be injected here by Vite during the build process -->
|
<!-- The stylesheet will be injected here by Vite during the build process -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!-- Vite will inject the correct <script> tag here during the build process -->
|
<!-- Vite will inject the correct <script> tag here during the build process -->
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Flyer Crawler",
|
"name": "Flyer Crawler",
|
||||||
"description": "Upload a grocery store flyer image to extract item details, prices, and quantities using AI. Get insights, meal plans, and compare prices to save money on your shopping.",
|
"description": "Upload a grocery store flyer image to extract item details, prices, and quantities using AI. Get insights, meal plans, and compare prices to save money on your shopping.",
|
||||||
"requestFramePermissions": [
|
"requestFramePermissions": ["geolocation", "microphone"]
|
||||||
"geolocation",
|
|
||||||
"microphone"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
4281
package-lock.json
generated
4281
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.13",
|
"version": "0.0.22",
|
||||||
"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",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const tailwindConfigPath = path.resolve(process.cwd(), 'tailwind.config.js');
|
|||||||
console.log(`[POSTCSS] Attempting to use Tailwind config at: ${tailwindConfigPath}`);
|
console.log(`[POSTCSS] Attempting to use Tailwind config at: ${tailwindConfigPath}`);
|
||||||
|
|
||||||
// Log to prove the imported config object is what we expect
|
// Log to prove the imported config object is what we expect
|
||||||
console.log('[POSTCSS] Imported tailwind.config.js object:', JSON.stringify(tailwindConfig, null, 2));
|
console.log(
|
||||||
|
'[POSTCSS] Imported tailwind.config.js object:',
|
||||||
|
JSON.stringify(tailwindConfig, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -1030,11 +1030,61 @@ DROP FUNCTION IF EXISTS public.fork_recipe(UUID, BIGINT);
|
|||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
||||||
RETURNS SETOF public.recipes
|
RETURNS SETOF public.recipes
|
||||||
LANGUAGE sql
|
LANGUAGE plpgsql
|
||||||
SECURITY INVOKER
|
SECURITY INVOKER
|
||||||
AS $$
|
AS $$
|
||||||
-- The entire forking logic is now encapsulated in a single, atomic database function.
|
DECLARE
|
||||||
SELECT * FROM public.fork_recipe(p_user_id, p_original_recipe_id);
|
new_recipe_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Create a copy of the recipe, linking it to the new user and the original recipe.
|
||||||
|
INSERT INTO public.recipes (
|
||||||
|
user_id,
|
||||||
|
original_recipe_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
prep_time_minutes,
|
||||||
|
cook_time_minutes,
|
||||||
|
servings,
|
||||||
|
photo_url,
|
||||||
|
calories_per_serving,
|
||||||
|
protein_grams,
|
||||||
|
fat_grams,
|
||||||
|
carb_grams,
|
||||||
|
status -- Forked recipes should be private by default
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_user_id,
|
||||||
|
p_original_recipe_id,
|
||||||
|
original.name || ' (Fork)', -- Append '(Fork)' to distinguish it
|
||||||
|
original.description,
|
||||||
|
original.instructions,
|
||||||
|
original.prep_time_minutes,
|
||||||
|
original.cook_time_minutes,
|
||||||
|
original.servings,
|
||||||
|
original.photo_url,
|
||||||
|
original.calories_per_serving,
|
||||||
|
original.protein_grams,
|
||||||
|
original.fat_grams,
|
||||||
|
original.carb_grams,
|
||||||
|
'private'
|
||||||
|
FROM public.recipes AS original
|
||||||
|
WHERE original.recipe_id = p_original_recipe_id
|
||||||
|
RETURNING recipe_id INTO new_recipe_id;
|
||||||
|
|
||||||
|
-- If the original recipe didn't exist, new_recipe_id will be null.
|
||||||
|
IF new_recipe_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Copy all ingredients, tags, and appliances from the original recipe to the new one.
|
||||||
|
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) SELECT new_recipe_id, master_item_id, quantity, unit FROM public.recipe_ingredients WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_tags (recipe_id, tag_id) SELECT new_recipe_id, tag_id FROM public.recipe_tags WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_appliances (recipe_id, appliance_id) SELECT new_recipe_id, appliance_id FROM public.recipe_appliances WHERE recipe_id = p_original_recipe_id;
|
||||||
|
|
||||||
|
-- 3. Return the newly created recipe record.
|
||||||
|
RETURN QUERY SELECT * FROM public.recipes WHERE recipe_id = new_recipe_id;
|
||||||
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
||||||
@@ -1566,4 +1616,3 @@ BEGIN
|
|||||||
bp.price_rank = 1;
|
bp.price_rank = 1;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,23 @@
|
|||||||
-- It is idempotent, meaning it can be run multiple times without causing errors.
|
-- It is idempotent, meaning it can be run multiple times without causing errors.
|
||||||
|
|
||||||
-- 1. Pre-populate the master grocery items dictionary.
|
-- 1. Pre-populate the master grocery items dictionary.
|
||||||
-- This block links generic items to their respective categories.
|
-- This MUST run after populating categories.
|
||||||
|
-- Renumbered to 2.
|
||||||
|
|
||||||
|
-- 2. Pre-populate the categories table from a predefined list.
|
||||||
|
-- Renumbered to 1. This MUST run before populating master_grocery_items.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.categories (name) VALUES
|
||||||
|
('Fruits & Vegetables'), ('Meat & Seafood'), ('Dairy & Eggs'), ('Bakery & Bread'),
|
||||||
|
('Pantry & Dry Goods'), ('Beverages'), ('Frozen Foods'), ('Snacks'), ('Household & Cleaning'),
|
||||||
|
('Personal Care & Health'), ('Baby & Child'), ('Pet Supplies'), ('Deli & Prepared Foods'),
|
||||||
|
('Canned Goods'), ('Condiments & Spices'), ('Breakfast & Cereal'), ('Organic'),
|
||||||
|
('International Foods'), ('Other/Miscellaneous')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Pre-populate the master grocery items dictionary.
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
fv_cat_id BIGINT; ms_cat_id BIGINT; de_cat_id BIGINT; bb_cat_id BIGINT; pdg_cat_id BIGINT;
|
fv_cat_id BIGINT; ms_cat_id BIGINT; de_cat_id BIGINT; bb_cat_id BIGINT; pdg_cat_id BIGINT;
|
||||||
@@ -53,18 +69,6 @@ BEGIN
|
|||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- 2. Pre-populate the categories table from a predefined list.
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.categories (name) VALUES
|
|
||||||
('Fruits & Vegetables'), ('Meat & Seafood'), ('Dairy & Eggs'), ('Bakery & Bread'),
|
|
||||||
('Pantry & Dry Goods'), ('Beverages'), ('Frozen Foods'), ('Snacks'), ('Household & Cleaning'),
|
|
||||||
('Personal Care & Health'), ('Baby & Child'), ('Pet Supplies'), ('Deli & Prepared Foods'),
|
|
||||||
('Canned Goods'), ('Condiments & Spices'), ('Breakfast & Cereal'), ('Organic'),
|
|
||||||
('International Foods'), ('Other/Miscellaneous')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 3. Pre-populate the brands and products tables.
|
-- 3. Pre-populate the brands and products tables.
|
||||||
-- This block adds common brands and links them to specific products.
|
-- This block adds common brands and links them to specific products.
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -110,7 +111,7 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
image_url TEXT NOT NULL,
|
image_url TEXT NOT NULL,
|
||||||
icon_url TEXT,
|
icon_url TEXT,
|
||||||
checksum TEXT UNIQUE,
|
checksum TEXT UNIQUE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
valid_from DATE,
|
valid_from DATE,
|
||||||
valid_to DATE,
|
valid_to DATE,
|
||||||
store_address TEXT,
|
store_address TEXT,
|
||||||
@@ -138,7 +139,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid
|
|||||||
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
||||||
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
is_allergen BOOLEAN DEFAULT false,
|
is_allergen BOOLEAN DEFAULT false,
|
||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -161,6 +162,38 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items (
|
|||||||
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
||||||
|
|
||||||
|
-- 23. Store brand information. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.brands (
|
||||||
|
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
logo_url TEXT,
|
||||||
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
|
-- 24. For specific products, linking a master item with a brand and size. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
|
brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
size TEXT,
|
||||||
|
upc_code TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
||||||
|
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
|
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
||||||
|
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
||||||
|
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
||||||
|
|
||||||
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
||||||
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
@@ -170,13 +203,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER,
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
category_name TEXT,
|
category_name TEXT,
|
||||||
unit_price JSONB,
|
unit_price JSONB,
|
||||||
view_count INTEGER DEFAULT 0 NOT NULL,
|
view_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT,
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
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
|
||||||
);
|
);
|
||||||
@@ -293,7 +326,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
||||||
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
@@ -358,7 +391,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s
|
|||||||
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
||||||
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
correction_type TEXT NOT NULL,
|
correction_type TEXT NOT NULL,
|
||||||
suggested_value TEXT NOT NULL,
|
suggested_value TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending' NOT NULL,
|
status TEXT DEFAULT 'pending' NOT NULL,
|
||||||
@@ -378,9 +411,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested
|
|||||||
-- 21. For prices submitted directly by users from in-store.
|
-- 21. For prices submitted directly by users from in-store.
|
||||||
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id),
|
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL,
|
price_in_cents INTEGER NOT NULL,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL,
|
||||||
@@ -408,38 +441,6 @@ COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer it
|
|||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
-- 23. Store brand information.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.brands (
|
|
||||||
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
logo_url TEXT,
|
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
|
||||||
|
|
||||||
-- 24. For specific products, linking a master item with a brand and size.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.products (
|
|
||||||
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
|
||||||
brand_id BIGINT REFERENCES public.brands(brand_id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
size TEXT,
|
|
||||||
upc_code TEXT UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
|
||||||
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
|
||||||
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
|
||||||
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
|
||||||
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
|
||||||
|
|
||||||
-- 25. Linking table for when one flyer is valid for multiple locations.
|
-- 25. Linking table for when one flyer is valid for multiple locations.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||||
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
@@ -495,7 +496,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi
|
|||||||
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
||||||
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -779,7 +780,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
||||||
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
@@ -843,7 +844,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER,
|
total_amount_cents INTEGER,
|
||||||
@@ -864,8 +865,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT REFERENCES public.products(product_id),
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
||||||
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
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ CREATE TABLE IF NOT EXISTS public.flyers (
|
|||||||
image_url TEXT NOT NULL,
|
image_url TEXT NOT NULL,
|
||||||
icon_url TEXT,
|
icon_url TEXT,
|
||||||
checksum TEXT UNIQUE,
|
checksum TEXT UNIQUE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
valid_from DATE,
|
valid_from DATE,
|
||||||
valid_to DATE,
|
valid_to DATE,
|
||||||
store_address TEXT,
|
store_address TEXT,
|
||||||
@@ -155,7 +155,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid
|
|||||||
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
CREATE TABLE IF NOT EXISTS public.master_grocery_items (
|
||||||
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
is_allergen BOOLEAN DEFAULT false,
|
is_allergen BOOLEAN DEFAULT false,
|
||||||
allergy_info JSONB,
|
allergy_info JSONB,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -178,6 +178,38 @@ CREATE TABLE IF NOT EXISTS public.user_watched_items (
|
|||||||
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_watched_items_master_item_id ON public.user_watched_items(master_item_id);
|
||||||
|
|
||||||
|
-- 23. Store brand information. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.brands (
|
||||||
|
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
logo_url TEXT,
|
||||||
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
||||||
|
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
||||||
|
|
||||||
|
-- 24. For specific products, linking a master item with a brand and size. (Moved up due to dependency in flyer_items)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
|
brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
size TEXT,
|
||||||
|
upc_code TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
||||||
|
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
||||||
|
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
||||||
|
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
||||||
|
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
||||||
|
|
||||||
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
-- 9. The 'flyer_items' table. This stores individual items from flyers.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
CREATE TABLE IF NOT EXISTS public.flyer_items (
|
||||||
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
flyer_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
@@ -187,13 +219,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items (
|
|||||||
price_in_cents INTEGER,
|
price_in_cents INTEGER,
|
||||||
quantity_num NUMERIC,
|
quantity_num NUMERIC,
|
||||||
quantity TEXT NOT NULL,
|
quantity TEXT NOT NULL,
|
||||||
category_id BIGINT REFERENCES public.categories(category_id),
|
category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL,
|
||||||
category_name TEXT,
|
category_name TEXT,
|
||||||
unit_price JSONB,
|
unit_price JSONB,
|
||||||
view_count INTEGER DEFAULT 0 NOT NULL,
|
view_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
click_count INTEGER DEFAULT 0 NOT NULL,
|
click_count INTEGER DEFAULT 0 NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT,
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
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
|
||||||
);
|
);
|
||||||
@@ -310,7 +342,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_list_items (
|
||||||
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
is_purchased BOOLEAN DEFAULT false NOT NULL,
|
||||||
@@ -375,7 +407,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s
|
|||||||
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
|
||||||
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
correction_type TEXT NOT NULL,
|
correction_type TEXT NOT NULL,
|
||||||
suggested_value TEXT NOT NULL,
|
suggested_value TEXT NOT NULL,
|
||||||
status TEXT DEFAULT 'pending' NOT NULL,
|
status TEXT DEFAULT 'pending' NOT NULL,
|
||||||
@@ -395,9 +427,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested
|
|||||||
-- 21. For prices submitted directly by users from in-store.
|
-- 21. For prices submitted directly by users from in-store.
|
||||||
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id),
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id),
|
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL,
|
price_in_cents INTEGER NOT NULL,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL,
|
upvotes INTEGER DEFAULT 0 NOT NULL,
|
||||||
@@ -424,38 +456,6 @@ COMMENT ON TABLE public.unmatched_flyer_items IS 'A queue for reviewing flyer it
|
|||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_flyer_item_id ON public.unmatched_flyer_items(flyer_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
CREATE INDEX IF NOT EXISTS idx_unmatched_flyer_items_pending ON public.unmatched_flyer_items (created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
-- 23. Store brand information.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.brands (
|
|
||||||
brand_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
logo_url TEXT,
|
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple Leaf", or "Kraft".';
|
|
||||||
COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand (e.g., President''s Choice), this links to the parent store.';
|
|
||||||
|
|
||||||
-- 24. For specific products, linking a master item with a brand and size.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.products (
|
|
||||||
product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
|
||||||
brand_id BIGINT REFERENCES public.brands(brand_id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
size TEXT,
|
|
||||||
upc_code TEXT UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
|
|
||||||
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
|
|
||||||
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
|
|
||||||
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
|
|
||||||
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_master_item_id ON public.products(master_item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON public.products(brand_id);
|
|
||||||
|
|
||||||
-- 25. Linking table for when one flyer is valid for multiple locations.
|
-- 25. Linking table for when one flyer is valid for multiple locations.
|
||||||
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||||
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
@@ -510,7 +510,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi
|
|||||||
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
|
||||||
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
@@ -796,7 +796,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin
|
|||||||
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
CREATE TABLE IF NOT EXISTS public.shopping_trip_items (
|
||||||
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
custom_item_name TEXT,
|
custom_item_name TEXT,
|
||||||
quantity NUMERIC NOT NULL,
|
quantity NUMERIC NOT NULL,
|
||||||
price_paid_cents INTEGER,
|
price_paid_cents INTEGER,
|
||||||
@@ -862,7 +862,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id),
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER,
|
total_amount_cents INTEGER,
|
||||||
@@ -883,8 +883,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items (
|
|||||||
raw_item_description TEXT NOT NULL,
|
raw_item_description TEXT NOT NULL,
|
||||||
quantity NUMERIC DEFAULT 1 NOT NULL,
|
quantity NUMERIC DEFAULT 1 NOT NULL,
|
||||||
price_paid_cents INTEGER NOT NULL,
|
price_paid_cents INTEGER NOT NULL,
|
||||||
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id),
|
master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL,
|
||||||
product_id BIGINT REFERENCES public.products(product_id),
|
product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL,
|
||||||
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')),
|
||||||
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
|
||||||
@@ -2128,11 +2128,61 @@ DROP FUNCTION IF EXISTS public.fork_recipe(UUID, BIGINT);
|
|||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
CREATE OR REPLACE FUNCTION public.fork_recipe(p_user_id UUID, p_original_recipe_id BIGINT)
|
||||||
RETURNS SETOF public.recipes
|
RETURNS SETOF public.recipes
|
||||||
LANGUAGE sql
|
LANGUAGE plpgsql
|
||||||
SECURITY INVOKER
|
SECURITY INVOKER
|
||||||
AS $$
|
AS $$
|
||||||
-- The entire forking logic is now encapsulated in a single, atomic database function.
|
DECLARE
|
||||||
SELECT * FROM public.fork_recipe(p_user_id, p_original_recipe_id);
|
new_recipe_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Create a copy of the recipe, linking it to the new user and the original recipe.
|
||||||
|
INSERT INTO public.recipes (
|
||||||
|
user_id,
|
||||||
|
original_recipe_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
prep_time_minutes,
|
||||||
|
cook_time_minutes,
|
||||||
|
servings,
|
||||||
|
photo_url,
|
||||||
|
calories_per_serving,
|
||||||
|
protein_grams,
|
||||||
|
fat_grams,
|
||||||
|
carb_grams,
|
||||||
|
status -- Forked recipes should be private by default
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_user_id,
|
||||||
|
p_original_recipe_id,
|
||||||
|
original.name || ' (Fork)', -- Append '(Fork)' to distinguish it
|
||||||
|
original.description,
|
||||||
|
original.instructions,
|
||||||
|
original.prep_time_minutes,
|
||||||
|
original.cook_time_minutes,
|
||||||
|
original.servings,
|
||||||
|
original.photo_url,
|
||||||
|
original.calories_per_serving,
|
||||||
|
original.protein_grams,
|
||||||
|
original.fat_grams,
|
||||||
|
original.carb_grams,
|
||||||
|
'private'
|
||||||
|
FROM public.recipes AS original
|
||||||
|
WHERE original.recipe_id = p_original_recipe_id
|
||||||
|
RETURNING recipe_id INTO new_recipe_id;
|
||||||
|
|
||||||
|
-- If the original recipe didn't exist, new_recipe_id will be null.
|
||||||
|
IF new_recipe_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Copy all ingredients, tags, and appliances from the original recipe to the new one.
|
||||||
|
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) SELECT new_recipe_id, master_item_id, quantity, unit FROM public.recipe_ingredients WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_tags (recipe_id, tag_id) SELECT new_recipe_id, tag_id FROM public.recipe_tags WHERE recipe_id = p_original_recipe_id;
|
||||||
|
INSERT INTO public.recipe_appliances (recipe_id, appliance_id) SELECT new_recipe_id, appliance_id FROM public.recipe_appliances WHERE recipe_id = p_original_recipe_id;
|
||||||
|
|
||||||
|
-- 3. Return the newly created recipe record.
|
||||||
|
RETURN QUERY SELECT * FROM public.recipes WHERE recipe_id = new_recipe_id;
|
||||||
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
202
src/db/seed.ts
202
src/db/seed.ts
@@ -6,10 +6,11 @@
|
|||||||
* DO NOT run this on a production database.
|
* DO NOT run this on a production database.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool, PoolClient } from 'pg';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
import { CATEGORIES } from '../types';
|
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
@@ -20,81 +21,55 @@ const pool = new Pool({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Declare client outside the try block so it's accessible in the finally block.
|
let client: PoolClient | undefined;
|
||||||
let client;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
client = await pool.connect();
|
client = await pool.connect();
|
||||||
logger.info('Connected to the database for seeding.');
|
logger.info('Connected to the database for seeding.');
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// 1. Clean the database
|
// 1. Clean the database by dropping and recreating the schema
|
||||||
logger.info('--- Wiping existing data... ---');
|
logger.info('--- Wiping and rebuilding schema... ---');
|
||||||
// Using TRUNCATE ... RESTART IDENTITY CASCADE is a powerful way to clean all tables
|
const dropScriptPath = path.resolve(process.cwd(), 'sql/drop_tables.sql');
|
||||||
// and reset auto-incrementing keys, while respecting foreign key relationships.
|
const dropSql = await fs.readFile(dropScriptPath, 'utf-8');
|
||||||
const tablesRes = await client.query(`
|
await client.query(dropSql);
|
||||||
SELECT tablename
|
logger.info('All tables dropped successfully.');
|
||||||
FROM pg_tables
|
|
||||||
WHERE schemaname = 'public'
|
|
||||||
-- Exclude PostGIS system tables from truncation to avoid permission errors.
|
|
||||||
AND tablename NOT IN ('spatial_ref_sys', 'geometry_columns')
|
|
||||||
`);
|
|
||||||
const tables = tablesRes.rows.map((row) => `"${row.tablename}"`).join(', ');
|
|
||||||
if (tables) {
|
|
||||||
await client.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
|
|
||||||
logger.info('All tables in public schema have been truncated.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Seed Categories
|
const schemaScriptPath = path.resolve(process.cwd(), 'sql/master_schema_rollup.sql');
|
||||||
logger.info('--- Seeding Categories... ---');
|
const schemaSql = await fs.readFile(schemaScriptPath, 'utf-8');
|
||||||
const categoryQuery = `INSERT INTO public.categories (name) VALUES ${CATEGORIES.map((_, i) => `($${i + 1})`).join(', ')} RETURNING category_id, name`;
|
await client.query(schemaSql);
|
||||||
const seededCategories = (
|
logger.info(
|
||||||
await client.query<{ category_id: number; name: string }>(categoryQuery, CATEGORIES)
|
'Schema rebuilt and static data seeded successfully from master_schema_rollup.sql.',
|
||||||
).rows;
|
);
|
||||||
const categoryMap = new Map(seededCategories.map((c) => [c.name, c.category_id]));
|
|
||||||
logger.info(`Seeded ${seededCategories.length} categories.`);
|
|
||||||
|
|
||||||
// 3. Seed Stores
|
// 2. Seed Additional Stores (if any beyond what's in the rollup)
|
||||||
logger.info('--- Seeding Stores... ---');
|
logger.info('--- Seeding Stores... ---');
|
||||||
const stores = ['Safeway', 'No Frills', 'Costco', 'Superstore'];
|
const stores = ['Safeway', 'No Frills', 'Costco', 'Superstore'];
|
||||||
const storeQuery = `INSERT INTO public.stores (name) VALUES ${stores.map((_, i) => `($${i + 1})`).join(', ')} RETURNING store_id, name`;
|
const storeQuery = `INSERT INTO public.stores (name) VALUES ${stores.map((_, i) => `($${i + 1})`).join(', ')} ON CONFLICT (name) DO NOTHING RETURNING store_id, name`;
|
||||||
const seededStores = (
|
await client.query<{ store_id: number; name: string }>(storeQuery, stores);
|
||||||
await client.query<{ store_id: number; name: string }>(storeQuery, stores)
|
const allStores = (
|
||||||
|
await client.query<{ store_id: number; name: string }>(
|
||||||
|
'SELECT store_id, name FROM public.stores',
|
||||||
|
)
|
||||||
).rows;
|
).rows;
|
||||||
const storeMap = new Map(seededStores.map((s) => [s.name, s.store_id]));
|
const storeMap = new Map(
|
||||||
logger.info(`Seeded ${seededStores.length} stores.`);
|
allStores.map((s: { name: string; store_id: number }) => [s.name, s.store_id]),
|
||||||
|
|
||||||
// 4. Seed Master Grocery Items
|
|
||||||
logger.info('--- Seeding Master Grocery Items... ---');
|
|
||||||
const masterItems = [
|
|
||||||
{ name: 'Chicken Breast, Boneless Skinless', category: 'Meat & Seafood' },
|
|
||||||
{ name: 'Ground Beef, Lean', category: 'Meat & Seafood' },
|
|
||||||
{ name: 'Avocado', category: 'Fruits & Vegetables' },
|
|
||||||
{ name: 'Bananas', category: 'Fruits & Vegetables' },
|
|
||||||
{ name: 'Broccoli', category: 'Fruits & Vegetables' },
|
|
||||||
{ name: 'Cheddar Cheese, Block', category: 'Dairy & Eggs' },
|
|
||||||
{ name: 'Milk, 2%', category: 'Dairy & Eggs' },
|
|
||||||
{ name: 'Eggs, Large', category: 'Dairy & Eggs' },
|
|
||||||
{ name: 'Whole Wheat Bread', category: 'Bakery & Bread' },
|
|
||||||
{ name: 'Pasta, Spaghetti', category: 'Pantry & Dry Goods' },
|
|
||||||
{ name: 'Canned Tomatoes, Diced', category: 'Canned Goods' },
|
|
||||||
{ name: 'Coca-Cola, 12-pack', category: 'Beverages' },
|
|
||||||
{ name: 'Frozen Pizza', category: 'Frozen Foods' },
|
|
||||||
{ name: 'Paper Towels', category: 'Household & Cleaning' },
|
|
||||||
];
|
|
||||||
const masterItemValues = masterItems
|
|
||||||
.map((item) => `('${item.name.replace(/'/g, "''")}', ${categoryMap.get(item.category)})`)
|
|
||||||
.join(', ');
|
|
||||||
const masterItemQuery = `INSERT INTO public.master_grocery_items (name, category_id) VALUES ${masterItemValues} RETURNING master_grocery_item_id, name`;
|
|
||||||
const seededMasterItems = (
|
|
||||||
await client.query<{ master_grocery_item_id: number; name: string }>(masterItemQuery)
|
|
||||||
).rows;
|
|
||||||
const masterItemMap = new Map(
|
|
||||||
seededMasterItems.map((item) => [item.name, item.master_grocery_item_id]),
|
|
||||||
);
|
);
|
||||||
logger.info(`Seeded ${seededMasterItems.length} master grocery items.`);
|
logger.info(`Seeded/verified ${allStores.length} total stores.`);
|
||||||
|
|
||||||
// 5. Seed Users & Profiles
|
// Fetch maps for items seeded by the master rollup script
|
||||||
|
const masterItemMap = new Map(
|
||||||
|
(
|
||||||
|
await client.query<{ master_grocery_item_id: number; name: string }>(
|
||||||
|
'SELECT master_grocery_item_id, name FROM public.master_grocery_items',
|
||||||
|
)
|
||||||
|
).rows.map((item: { name: string; master_grocery_item_id: number }) => [
|
||||||
|
item.name,
|
||||||
|
item.master_grocery_item_id,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Seed Users & Profiles
|
||||||
logger.info('--- Seeding Users & Profiles... ---');
|
logger.info('--- Seeding Users & Profiles... ---');
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const adminPassHash = await bcrypt.hash('adminpass', saltRounds);
|
const adminPassHash = await bcrypt.hash('adminpass', saltRounds);
|
||||||
@@ -126,7 +101,7 @@ async function main() {
|
|||||||
const userId = userRes.rows[0].user_id;
|
const userId = userRes.rows[0].user_id;
|
||||||
logger.info('Seeded regular user (user@example.com / userpass)');
|
logger.info('Seeded regular user (user@example.com / userpass)');
|
||||||
|
|
||||||
// 6. Seed a Flyer
|
// 4. Seed a Flyer
|
||||||
logger.info('--- Seeding a Sample Flyer... ---');
|
logger.info('--- Seeding a Sample Flyer... ---');
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const validFrom = new Date(today);
|
const validFrom = new Date(today);
|
||||||
@@ -146,29 +121,29 @@ async function main() {
|
|||||||
const flyerId = flyerRes.rows[0].flyer_id;
|
const flyerId = flyerRes.rows[0].flyer_id;
|
||||||
logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`);
|
logger.info(`Seeded flyer for Safeway (ID: ${flyerId}).`);
|
||||||
|
|
||||||
// 7. Seed Flyer Items
|
// 5. Seed Flyer Items
|
||||||
logger.info('--- Seeding Flyer Items... ---');
|
logger.info('--- Seeding Flyer Items... ---');
|
||||||
const flyerItems = [
|
const flyerItems = [
|
||||||
{
|
{
|
||||||
name: 'Chicken Breast, Boneless Skinless',
|
name: 'chicken breast',
|
||||||
price_display: '$3.99 /lb',
|
price_display: '$3.99 /lb',
|
||||||
price_in_cents: 399,
|
price_in_cents: 399,
|
||||||
quantity: 'per lb',
|
quantity: 'per lb',
|
||||||
master_item_id: masterItemMap.get('Chicken Breast, Boneless Skinless'),
|
master_item_id: masterItemMap.get('chicken breast'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Avocado',
|
name: 'avocados',
|
||||||
price_display: '2 for $5.00',
|
price_display: '2 for $5.00',
|
||||||
price_in_cents: 250,
|
price_in_cents: 250,
|
||||||
quantity: 'each',
|
quantity: 'each',
|
||||||
master_item_id: masterItemMap.get('Avocado'),
|
master_item_id: masterItemMap.get('avocados'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Coca-Cola 12-pack',
|
name: 'soda',
|
||||||
price_display: '$6.99',
|
price_display: '$6.99',
|
||||||
price_in_cents: 699,
|
price_in_cents: 699,
|
||||||
quantity: '12x355ml',
|
quantity: '12x355ml',
|
||||||
master_item_id: masterItemMap.get('Coca-Cola, 12-pack'),
|
master_item_id: masterItemMap.get('soda'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Unmatched Sample Item',
|
name: 'Unmatched Sample Item',
|
||||||
@@ -194,12 +169,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
logger.info(`Seeded ${flyerItems.length} items for the Safeway flyer.`);
|
logger.info(`Seeded ${flyerItems.length} items for the Safeway flyer.`);
|
||||||
|
|
||||||
// 8. Seed Watched Items for the user
|
// 6. Seed Watched Items for the user
|
||||||
logger.info('--- Seeding Watched Items... ---');
|
logger.info('--- Seeding Watched Items... ---');
|
||||||
const watchedItemIds = [
|
const watchedItemIds = [
|
||||||
masterItemMap.get('Chicken Breast, Boneless Skinless'),
|
masterItemMap.get('chicken breast'),
|
||||||
masterItemMap.get('Avocado'),
|
masterItemMap.get('avocados'),
|
||||||
masterItemMap.get('Ground Beef, Lean'),
|
masterItemMap.get('ground beef'),
|
||||||
];
|
];
|
||||||
for (const itemId of watchedItemIds) {
|
for (const itemId of watchedItemIds) {
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
@@ -211,7 +186,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
logger.info(`Seeded ${watchedItemIds.length} watched items for Test User.`);
|
logger.info(`Seeded ${watchedItemIds.length} watched items for Test User.`);
|
||||||
|
|
||||||
// 9. Seed a Shopping List
|
// 7. Seed a Shopping List
|
||||||
logger.info('--- Seeding a Shopping List... ---');
|
logger.info('--- Seeding a Shopping List... ---');
|
||||||
const listRes = await client.query<{ shopping_list_id: number }>(
|
const listRes = await client.query<{ shopping_list_id: number }>(
|
||||||
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id',
|
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id',
|
||||||
@@ -220,8 +195,8 @@ async function main() {
|
|||||||
const listId = listRes.rows[0].shopping_list_id;
|
const listId = listRes.rows[0].shopping_list_id;
|
||||||
|
|
||||||
const shoppingListItems = [
|
const shoppingListItems = [
|
||||||
{ master_item_id: masterItemMap.get('Milk, 2%'), quantity: 1 },
|
{ master_item_id: masterItemMap.get('milk'), quantity: 1 },
|
||||||
{ master_item_id: masterItemMap.get('Eggs, Large'), quantity: 1 },
|
{ master_item_id: masterItemMap.get('eggs'), quantity: 1 },
|
||||||
{ custom_item_name: 'Specialty Hot Sauce', quantity: 1 },
|
{ custom_item_name: 'Specialty Hot Sauce', quantity: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -235,75 +210,6 @@ async function main() {
|
|||||||
`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`,
|
`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 10. Seed Brands
|
|
||||||
logger.info('--- Seeding Brands... ---');
|
|
||||||
const brands = [
|
|
||||||
'Coca-Cola',
|
|
||||||
'Kraft',
|
|
||||||
'Maple Leaf',
|
|
||||||
"Dempster's",
|
|
||||||
'No Name',
|
|
||||||
"President's Choice",
|
|
||||||
];
|
|
||||||
const brandQuery = `INSERT INTO public.brands (name) VALUES ${brands.map((_, i) => `($${i + 1})`).join(', ')} ON CONFLICT (name) DO NOTHING`;
|
|
||||||
await client.query(brandQuery, brands);
|
|
||||||
logger.info(`Seeded ${brands.length} brands.`);
|
|
||||||
|
|
||||||
// Link store-specific brands
|
|
||||||
const loblawsId = storeMap.get('Loblaws');
|
|
||||||
if (loblawsId) {
|
|
||||||
await client.query('UPDATE public.brands SET store_id = $1 WHERE name = $2 OR name = $3', [
|
|
||||||
loblawsId,
|
|
||||||
'No Name',
|
|
||||||
"President's Choice",
|
|
||||||
]);
|
|
||||||
logger.info('Linked store brands to Loblaws.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. Seed Recipes
|
|
||||||
logger.info('--- Seeding Recipes... ---');
|
|
||||||
const recipes = [
|
|
||||||
{
|
|
||||||
name: 'Simple Chicken and Rice',
|
|
||||||
description: 'A quick and healthy weeknight meal.',
|
|
||||||
instructions: '1. Cook rice. 2. Cook chicken. 3. Combine.',
|
|
||||||
prep: 10,
|
|
||||||
cook: 20,
|
|
||||||
servings: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Classic Spaghetti Bolognese',
|
|
||||||
description: 'A rich and hearty meat sauce.',
|
|
||||||
instructions: '1. Brown beef. 2. Add sauce. 3. Simmer.',
|
|
||||||
prep: 15,
|
|
||||||
cook: 45,
|
|
||||||
servings: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Vegetable Stir-fry',
|
|
||||||
description: 'A fast and flavorful vegetarian meal.',
|
|
||||||
instructions: '1. Chop veggies. 2. Stir-fry. 3. Add sauce.',
|
|
||||||
prep: 10,
|
|
||||||
cook: 10,
|
|
||||||
servings: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
for (const recipe of recipes) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'public') ON CONFLICT (name) WHERE user_id IS NULL DO NOTHING`,
|
|
||||||
[
|
|
||||||
recipe.name,
|
|
||||||
recipe.description,
|
|
||||||
recipe.instructions,
|
|
||||||
recipe.prep,
|
|
||||||
recipe.cook,
|
|
||||||
recipe.servings,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logger.info(`Seeded ${recipes.length} recipes.`);
|
|
||||||
|
|
||||||
// --- SEED SCRIPT DEBUG LOGGING ---
|
// --- SEED SCRIPT DEBUG LOGGING ---
|
||||||
// Corrected the query to be unambiguous by specifying the table alias for each column.
|
// Corrected the query to be unambiguous by specifying the table alias for each column.
|
||||||
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
|
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
This single directive replaces @tailwind base, components, and utilities.
|
This single directive replaces @tailwind base, components, and utilities.
|
||||||
It is the new entry point for all of Tailwind's generated CSS.
|
It is the new entry point for all of Tailwind's generated CSS.
|
||||||
*/
|
*/
|
||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
This is the new v4 directive that tells the @tailwindcss/postcss plugin
|
This is the new v4 directive that tells the @tailwindcss/postcss plugin
|
||||||
@@ -12,4 +12,3 @@
|
|||||||
Since tailwind.config.js is in the root and this is in src/, the path is '../tailwind.config.js'.
|
Since tailwind.config.js is in the root and this is in src/, the path is '../tailwind.config.js'.
|
||||||
*/
|
*/
|
||||||
@config '../tailwind.config.js';
|
@config '../tailwind.config.js';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import './index.css';
|
|||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Could not find root element to mount to");
|
throw new Error('Could not find root element to mount to');
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
@@ -19,6 +19,5 @@ root.render(
|
|||||||
<App />
|
<App />
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,12 +86,15 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
// Arrange
|
// Arrange
|
||||||
const mkdirError = new Error('EACCES: permission denied');
|
const mkdirError = new Error('EACCES: permission denied');
|
||||||
vi.resetModules(); // Reset modules to re-run top-level code
|
vi.resetModules(); // Reset modules to re-run top-level code
|
||||||
vi.doMock('node:fs', () => ({
|
vi.doMock('node:fs', () => {
|
||||||
|
const mockFs = {
|
||||||
...fs,
|
...fs,
|
||||||
mkdirSync: vi.fn().mockImplementation(() => {
|
mkdirSync: vi.fn().mockImplementation(() => {
|
||||||
throw mkdirError;
|
throw mkdirError;
|
||||||
}),
|
}),
|
||||||
}));
|
};
|
||||||
|
return { ...mockFs, default: mockFs };
|
||||||
|
});
|
||||||
const { logger } = await import('../services/logger.server');
|
const { logger } = await import('../services/logger.server');
|
||||||
|
|
||||||
// Act: Dynamically import the router to trigger the mkdirSync call
|
// Act: Dynamically import the router to trigger the mkdirSync call
|
||||||
@@ -617,6 +620,14 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
expect(response.body.text).toContain('server-generated quick insight');
|
expect(response.body.text).toContain('server-generated quick insight');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /quick-insights should accept items with "item" property instead of "name"', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/ai/quick-insights')
|
||||||
|
.send({ items: [{ item: 'test item' }] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /quick-insights should return 500 on a generic error', async () => {
|
it('POST /quick-insights should return 500 on a generic error', async () => {
|
||||||
// To hit the catch block, we can simulate an error by making the logger throw.
|
// To hit the catch block, we can simulate an error by making the logger throw.
|
||||||
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -88,10 +88,17 @@ const rescanAreaSchema = z.object({
|
|||||||
|
|
||||||
const flyerItemForAnalysisSchema = z
|
const flyerItemForAnalysisSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: requiredString('Item name is required.'),
|
item: z.string().nullish(),
|
||||||
// Allow other properties to pass through without validation
|
name: z.string().nullish(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough()
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
(data.item && data.item.trim().length > 0) || (data.name && data.name.trim().length > 0),
|
||||||
|
{
|
||||||
|
message: "Item identifier is required (either 'item' or 'name').",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const insightsSchema = z.object({
|
const insightsSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -618,7 +617,9 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
const setCookieHeader = response.headers['set-cookie'];
|
const setCookieHeader = response.headers['set-cookie'];
|
||||||
expect(setCookieHeader).toBeDefined();
|
expect(setCookieHeader).toBeDefined();
|
||||||
expect(setCookieHeader[0]).toContain('refreshToken=;');
|
expect(setCookieHeader[0]).toContain('refreshToken=;');
|
||||||
expect(setCookieHeader[0]).toContain('Expires=Thu, 01 Jan 1970');
|
// Check for Max-Age=0, which is the modern way to expire a cookie.
|
||||||
|
// The 'Expires' attribute is a fallback and its exact value can be inconsistent.
|
||||||
|
expect(setCookieHeader[0]).toContain('Max-Age=0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
|
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ router.post('/logout', async (req: Request, res: Response) => {
|
|||||||
// Instruct the browser to clear the cookie by setting its expiration to the past.
|
// Instruct the browser to clear the cookie by setting its expiration to the past.
|
||||||
res.cookie('refreshToken', '', {
|
res.cookie('refreshToken', '', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
expires: new Date(0),
|
maxAge: 0, // Use maxAge for modern compatibility; Express sets 'Expires' as a fallback.
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
});
|
});
|
||||||
res.status(200).json({ message: 'Logged out successfully.' });
|
res.status(200).json({ message: 'Logged out successfully.' });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import * as aiApiClient from './aiApiClient';
|
import * as aiApiClient from './aiApiClient';
|
||||||
import { AiAnalysisService } from './aiAnalysisService';
|
import { AiAnalysisService } from './aiAnalysisService';
|
||||||
|
import { createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
vi.mock('./aiApiClient');
|
vi.mock('./aiApiClient');
|
||||||
@@ -56,7 +57,7 @@ describe('AiAnalysisService', () => {
|
|||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const result = await service.searchWeb([]);
|
const result = await service.searchWeb([createMockFlyerItem({ item: 'test' })]);
|
||||||
|
|
||||||
expect(result.text).toBe('Search results');
|
expect(result.text).toBe('Search results');
|
||||||
expect(result.sources).toEqual([{ uri: 'https://example.com', title: 'Example' }]);
|
expect(result.sources).toEqual([{ uri: 'https://example.com', title: 'Example' }]);
|
||||||
@@ -68,7 +69,7 @@ describe('AiAnalysisService', () => {
|
|||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const result = await service.searchWeb([]);
|
const result = await service.searchWeb([createMockFlyerItem({ item: 'test' })]);
|
||||||
|
|
||||||
expect(result.text).toBe('Search results');
|
expect(result.text).toBe('Search results');
|
||||||
expect(result.sources).toEqual([]);
|
expect(result.sources).toEqual([]);
|
||||||
@@ -83,7 +84,7 @@ describe('AiAnalysisService', () => {
|
|||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const result = await service.searchWeb([]);
|
const result = await service.searchWeb([createMockFlyerItem({ item: 'test' })]);
|
||||||
|
|
||||||
expect(result.sources).toEqual([{ uri: '', title: 'Untitled' }]);
|
expect(result.sources).toEqual([{ uri: '', title: 'Untitled' }]);
|
||||||
});
|
});
|
||||||
@@ -92,7 +93,9 @@ describe('AiAnalysisService', () => {
|
|||||||
const apiError = new Error('API is down');
|
const apiError = new Error('API is down');
|
||||||
vi.mocked(aiApiClient.searchWeb).mockRejectedValue(apiError);
|
vi.mocked(aiApiClient.searchWeb).mockRejectedValue(apiError);
|
||||||
|
|
||||||
await expect(service.searchWeb([])).rejects.toThrow(apiError);
|
await expect(service.searchWeb([createMockFlyerItem({ item: 'test' })])).rejects.toThrow(
|
||||||
|
apiError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ export class AiAnalysisService {
|
|||||||
*/
|
*/
|
||||||
async searchWeb(items: FlyerItem[]): Promise<GroundedResponse> {
|
async searchWeb(items: FlyerItem[]): Promise<GroundedResponse> {
|
||||||
logger.info('[AiAnalysisService] searchWeb called.');
|
logger.info('[AiAnalysisService] searchWeb called.');
|
||||||
|
// Construct a query string from the item names.
|
||||||
|
const query = items.map((item) => item.item).join(', ');
|
||||||
// The API client returns a specific shape that we need to await the JSON from
|
// The API client returns a specific shape that we need to await the JSON from
|
||||||
const response: { text: string; sources: RawSource[] } = await aiApiClient
|
const response: { text: string; sources: RawSource[] } = await aiApiClient
|
||||||
.searchWeb(items)
|
.searchWeb(query)
|
||||||
.then((res) => res.json());
|
.then((res) => res.json());
|
||||||
// Normalize sources to a consistent format.
|
// Normalize sources to a consistent format.
|
||||||
const mappedSources = (response.sources || []).map(
|
const mappedSources = (response.sources || []).map(
|
||||||
|
|||||||
@@ -282,15 +282,15 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('searchWeb', () => {
|
describe('searchWeb', () => {
|
||||||
it('should send items as JSON in the body', async () => {
|
it('should send query as JSON in the body', async () => {
|
||||||
const items = [createMockFlyerItem({ item: 'search me' })];
|
const query = 'search me';
|
||||||
await aiApiClient.searchWeb(items, undefined, 'test-token');
|
await aiApiClient.searchWeb(query, undefined, 'test-token');
|
||||||
|
|
||||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||||
const req = requestSpy.mock.calls[0][0];
|
const req = requestSpy.mock.calls[0][0];
|
||||||
|
|
||||||
expect(req.endpoint).toBe('search-web');
|
expect(req.endpoint).toBe('search-web');
|
||||||
expect(req.body).toEqual({ items });
|
expect(req.body).toEqual({ query });
|
||||||
expect(req.headers.get('Authorization')).toBe('Bearer test-token');
|
expect(req.headers.get('Authorization')).toBe('Bearer test-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const getDeepDiveAnalysis = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const searchWeb = async (
|
export const searchWeb = async (
|
||||||
items: Partial<FlyerItem>[],
|
query: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
tokenOverride?: string,
|
tokenOverride?: string,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
@@ -144,7 +144,7 @@ export const searchWeb = async (
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ items }),
|
body: JSON.stringify({ query }),
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
{ tokenOverride, signal },
|
{ tokenOverride, signal },
|
||||||
|
|||||||
@@ -624,14 +624,10 @@ describe('User DB Service', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundError if token is not found', async () => {
|
it('should return undefined if token is not found', async () => {
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||||
await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow(
|
const result = await userRepo.findUserByRefreshToken('a-token', mockLogger);
|
||||||
NotFoundError,
|
expect(result).toBeUndefined();
|
||||||
);
|
|
||||||
await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow(
|
|
||||||
'User not found for the given refresh token.',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw a generic error if the database query fails', async () => {
|
it('should throw a generic error if the database query fails', async () => {
|
||||||
|
|||||||
@@ -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.');
|
||||||
@@ -443,23 +434,21 @@ export class UserRepository {
|
|||||||
* @param refreshToken The refresh token to look up.
|
* @param refreshToken The refresh token to look up.
|
||||||
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
|
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
|
||||||
*/
|
*/
|
||||||
// prettier-ignore
|
async findUserByRefreshToken(
|
||||||
async findUserByRefreshToken(refreshToken: string, logger: Logger): Promise<{ user_id: string; email: string; }> {
|
refreshToken: string,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<{ user_id: string; email: string } | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await this.db.query<{ user_id: string; email: string }>(
|
const res = await this.db.query<{ user_id: string; email: string }>(
|
||||||
'SELECT user_id, email FROM public.users WHERE refresh_token = $1',
|
'SELECT user_id, email FROM public.users WHERE refresh_token = $1',
|
||||||
[refreshToken]
|
[refreshToken],
|
||||||
);
|
);
|
||||||
if ((res.rowCount ?? 0) === 0) {
|
if ((res.rowCount ?? 0) === 0) {
|
||||||
throw new NotFoundError('User not found for the given refresh token.');
|
return undefined;
|
||||||
}
|
}
|
||||||
return res.rows[0];
|
return res.rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NotFoundError) throw error;
|
logger.error({ err: error }, 'Database error in findUserByRefreshToken');
|
||||||
logger.error(
|
|
||||||
{ err: error instanceof Error ? error.message : error },
|
|
||||||
'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 +463,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 +487,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 +507,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 +524,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 +545,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 +564,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 +581,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 +612,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 +634,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 +669,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.');
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
// src/tests/integration/admin.integration.test.ts
|
// src/tests/integration/admin.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
const request = supertest(app);
|
||||||
|
|
||||||
describe('Admin API Routes Integration Tests', () => {
|
describe('Admin API Routes Integration Tests', () => {
|
||||||
let adminToken: string;
|
let adminToken: string;
|
||||||
let adminUser: UserProfile;
|
let adminUser: UserProfile;
|
||||||
@@ -42,8 +48,10 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
describe('GET /api/admin/stats', () => {
|
describe('GET /api/admin/stats', () => {
|
||||||
it('should allow an admin to fetch application stats', async () => {
|
it('should allow an admin to fetch application stats', async () => {
|
||||||
const response = await apiClient.getApplicationStats(adminToken);
|
const response = await request
|
||||||
const stats = await response.json();
|
.get('/api/admin/stats')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
const stats = response.body;
|
||||||
expect(stats).toBeDefined();
|
expect(stats).toBeDefined();
|
||||||
expect(stats).toHaveProperty('flyerCount');
|
expect(stats).toHaveProperty('flyerCount');
|
||||||
expect(stats).toHaveProperty('userCount');
|
expect(stats).toHaveProperty('userCount');
|
||||||
@@ -51,18 +59,21 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching application stats', async () => {
|
it('should forbid a regular user from fetching application stats', async () => {
|
||||||
const response = await apiClient.getApplicationStats(regularUserToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(false);
|
.get('/api/admin/stats')
|
||||||
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
const errorData = await response.json();
|
const errorData = response.body;
|
||||||
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/admin/stats/daily', () => {
|
describe('GET /api/admin/stats/daily', () => {
|
||||||
it('should allow an admin to fetch daily stats', async () => {
|
it('should allow an admin to fetch daily stats', async () => {
|
||||||
const response = await apiClient.getDailyStats(adminToken);
|
const response = await request
|
||||||
const dailyStats = await response.json();
|
.get('/api/admin/stats/daily')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
const dailyStats = response.body;
|
||||||
expect(dailyStats).toBeDefined();
|
expect(dailyStats).toBeDefined();
|
||||||
expect(Array.isArray(dailyStats)).toBe(true);
|
expect(Array.isArray(dailyStats)).toBe(true);
|
||||||
// We just created users in beforeAll, so we should have data
|
// We just created users in beforeAll, so we should have data
|
||||||
@@ -73,10 +84,11 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching daily stats', async () => {
|
it('should forbid a regular user from fetching daily stats', async () => {
|
||||||
const response = await apiClient.getDailyStats(regularUserToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(false);
|
.get('/api/admin/stats/daily')
|
||||||
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
const errorData = await response.json();
|
const errorData = response.body;
|
||||||
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,25 +97,30 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
it('should allow an admin to fetch suggested corrections', async () => {
|
it('should allow an admin to fetch suggested corrections', async () => {
|
||||||
// This test just verifies access and correct response shape.
|
// This test just verifies access and correct response shape.
|
||||||
// More detailed tests would require seeding corrections.
|
// More detailed tests would require seeding corrections.
|
||||||
const response = await apiClient.getSuggestedCorrections(adminToken);
|
const response = await request
|
||||||
const corrections = await response.json();
|
.get('/api/admin/corrections')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
const corrections = response.body;
|
||||||
expect(corrections).toBeDefined();
|
expect(corrections).toBeDefined();
|
||||||
expect(Array.isArray(corrections)).toBe(true);
|
expect(Array.isArray(corrections)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching suggested corrections', async () => {
|
it('should forbid a regular user from fetching suggested corrections', async () => {
|
||||||
const response = await apiClient.getSuggestedCorrections(regularUserToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(false);
|
.get('/api/admin/corrections')
|
||||||
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
const errorData = await response.json();
|
const errorData = response.body;
|
||||||
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/admin/brands', () => {
|
describe('GET /api/admin/brands', () => {
|
||||||
it('should allow an admin to fetch all brands', async () => {
|
it('should allow an admin to fetch all brands', async () => {
|
||||||
const response = await apiClient.fetchAllBrands(adminToken);
|
const response = await request
|
||||||
const brands = await response.json();
|
.get('/api/admin/brands')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
const brands = response.body;
|
||||||
expect(brands).toBeDefined();
|
expect(brands).toBeDefined();
|
||||||
expect(Array.isArray(brands)).toBe(true);
|
expect(Array.isArray(brands)).toBe(true);
|
||||||
// Even if no brands exist, it should return an array.
|
// Even if no brands exist, it should return an array.
|
||||||
@@ -112,10 +129,11 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching all brands', async () => {
|
it('should forbid a regular user from fetching all brands', async () => {
|
||||||
const response = await apiClient.fetchAllBrands(regularUserToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(false);
|
.get('/api/admin/brands')
|
||||||
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
const errorData = await response.json();
|
const errorData = response.body;
|
||||||
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -170,8 +188,10 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should allow an admin to approve a correction', async () => {
|
it('should allow an admin to approve a correction', async () => {
|
||||||
// Act: Approve the correction.
|
// Act: Approve the correction.
|
||||||
const response = await apiClient.approveCorrection(testCorrectionId, adminToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(true);
|
.post(`/api/admin/corrections/${testCorrectionId}/approve`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
// Assert: Verify the flyer item's price was updated and the correction status changed.
|
// Assert: Verify the flyer item's price was updated and the correction status changed.
|
||||||
const { rows: itemRows } = await getPool().query(
|
const { rows: itemRows } = await getPool().query(
|
||||||
@@ -189,8 +209,10 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should allow an admin to reject a correction', async () => {
|
it('should allow an admin to reject a correction', async () => {
|
||||||
// Act: Reject the correction.
|
// Act: Reject the correction.
|
||||||
const response = await apiClient.rejectCorrection(testCorrectionId, adminToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(true);
|
.post(`/api/admin/corrections/${testCorrectionId}/reject`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
// Assert: Verify the correction status changed.
|
// Assert: Verify the correction status changed.
|
||||||
const { rows: correctionRows } = await getPool().query(
|
const { rows: correctionRows } = await getPool().query(
|
||||||
@@ -202,12 +224,11 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should allow an admin to update a correction', async () => {
|
it('should allow an admin to update a correction', async () => {
|
||||||
// Act: Update the suggested value of the correction.
|
// Act: Update the suggested value of the correction.
|
||||||
const response = await apiClient.updateSuggestedCorrection(
|
const response = await request
|
||||||
testCorrectionId,
|
.put(`/api/admin/corrections/${testCorrectionId}`)
|
||||||
'300',
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
adminToken,
|
.send({ suggested_value: '300' });
|
||||||
);
|
const updatedCorrection = response.body;
|
||||||
const updatedCorrection = await response.json();
|
|
||||||
|
|
||||||
// Assert: Verify the API response and the database state.
|
// Assert: Verify the API response and the database state.
|
||||||
expect(updatedCorrection.suggested_value).toBe('300');
|
expect(updatedCorrection.suggested_value).toBe('300');
|
||||||
@@ -227,8 +248,11 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
const recipeId = recipeRes.rows[0].recipe_id;
|
const recipeId = recipeRes.rows[0].recipe_id;
|
||||||
|
|
||||||
// Act: Update the status to 'public'.
|
// Act: Update the status to 'public'.
|
||||||
const response = await apiClient.updateRecipeStatus(recipeId, 'public', adminToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(true);
|
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ status: 'public' });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
// Assert: Verify the status was updated in the database.
|
// Assert: Verify the status was updated in the database.
|
||||||
const { rows: updatedRecipeRows } = await getPool().query(
|
const { rows: updatedRecipeRows } = await getPool().query(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/tests/integration/ai.integration.test.ts
|
// src/tests/integration/ai.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import * as aiApiClient from '../../services/aiApiClient';
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
@@ -9,6 +10,8 @@ import { createAndLoginUser } from '../utils/testHelpers';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const request = supertest(app);
|
||||||
|
|
||||||
interface TestGeolocationCoordinates {
|
interface TestGeolocationCoordinates {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -44,46 +47,67 @@ describe('AI API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/check-flyer should return a boolean', async () => {
|
it('POST /api/ai/check-flyer should return a boolean', async () => {
|
||||||
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
const response = await request
|
||||||
const response = await aiApiClient.isImageAFlyer(mockImageFile, authToken);
|
.post('/api/ai/check-flyer')
|
||||||
const result = await response.json();
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.attach('image', Buffer.from('content'), 'test.jpg');
|
||||||
|
const result = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
// The backend is stubbed to always return true for this check
|
// The backend is stubbed to always return true for this check
|
||||||
expect(result.is_flyer).toBe(true);
|
expect(result.is_flyer).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/extract-address should return a stubbed address', async () => {
|
it('POST /api/ai/extract-address should return a stubbed address', async () => {
|
||||||
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
const response = await request
|
||||||
const response = await aiApiClient.extractAddressFromImage(mockImageFile, authToken);
|
.post('/api/ai/extract-address')
|
||||||
const result = await response.json();
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
expect(result.address).toBe('123 AI Street, Server City');
|
.attach('image', Buffer.from('content'), 'test.jpg');
|
||||||
|
const result = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(result.address).toBe('not identified');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/extract-logo should return a stubbed response', async () => {
|
it('POST /api/ai/extract-logo should return a stubbed response', async () => {
|
||||||
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
const response = await request
|
||||||
const response = await aiApiClient.extractLogoFromImage([mockImageFile], authToken);
|
.post('/api/ai/extract-logo')
|
||||||
const result = await response.json();
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.attach('images', Buffer.from('content'), 'test.jpg');
|
||||||
|
const result = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
expect(result).toEqual({ store_logo_base_64: null });
|
expect(result).toEqual({ store_logo_base_64: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/quick-insights should return a stubbed insight', async () => {
|
it('POST /api/ai/quick-insights should return a stubbed insight', async () => {
|
||||||
const response = await aiApiClient.getQuickInsights([], undefined, authToken);
|
const response = await request
|
||||||
const result = await response.json();
|
.post('/api/ai/quick-insights')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({ items: [{ item: 'test' }] });
|
||||||
|
const result = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
expect(result.text).toBe('This is a server-generated quick insight: buy the cheap stuff!');
|
expect(result.text).toBe('This is a server-generated quick insight: buy the cheap stuff!');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/deep-dive should return a stubbed analysis', async () => {
|
it('POST /api/ai/deep-dive should return a stubbed analysis', async () => {
|
||||||
const response = await aiApiClient.getDeepDiveAnalysis([], undefined, authToken);
|
const response = await request
|
||||||
const result = await response.json();
|
.post('/api/ai/deep-dive')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({ items: [{ item: 'test' }] });
|
||||||
|
const result = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
expect(result.text).toBe('This is a server-generated deep dive analysis. It is very detailed.');
|
expect(result.text).toBe('This is a server-generated deep dive analysis. It is very detailed.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/search-web should return a stubbed search result', async () => {
|
it('POST /api/ai/search-web should return a stubbed search result', async () => {
|
||||||
const response = await aiApiClient.searchWeb([], undefined, authToken);
|
const response = await request
|
||||||
const result = await response.json();
|
.post('/api/ai/search-web')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({ query: 'test query' });
|
||||||
|
const result = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
expect(result).toEqual({ text: 'The web says this is good.', sources: [] });
|
expect(result).toEqual({ text: 'The web says this is good.', sources: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/plan-trip should return a stubbed trip plan', async () => {
|
it('POST /api/ai/plan-trip should return an error as the feature is disabled', async () => {
|
||||||
// The GeolocationCoordinates type requires more than just lat/lng.
|
// The GeolocationCoordinates type requires more than just lat/lng.
|
||||||
// We create a complete mock object to satisfy the type.
|
// We create a complete mock object to satisfy the type.
|
||||||
const mockLocation: TestGeolocationCoordinates = {
|
const mockLocation: TestGeolocationCoordinates = {
|
||||||
@@ -94,38 +118,50 @@ describe('AI API Routes Integration Tests', () => {
|
|||||||
altitudeAccuracy: null,
|
altitudeAccuracy: null,
|
||||||
heading: null,
|
heading: null,
|
||||||
speed: null,
|
speed: null,
|
||||||
toJSON: () => ({}),
|
toJSON: function () {
|
||||||
|
return {
|
||||||
|
latitude: this.latitude,
|
||||||
|
longitude: this.longitude,
|
||||||
|
accuracy: this.accuracy,
|
||||||
|
altitude: this.altitude,
|
||||||
|
altitudeAccuracy: this.altitudeAccuracy,
|
||||||
|
heading: this.heading,
|
||||||
|
speed: this.speed,
|
||||||
};
|
};
|
||||||
const response = await aiApiClient.planTripWithMaps(
|
},
|
||||||
[],
|
};
|
||||||
undefined,
|
const mockStore = {
|
||||||
mockLocation,
|
name: 'Test Store for Trip',
|
||||||
undefined,
|
store_id: 1,
|
||||||
authToken,
|
created_at: new Date().toISOString(),
|
||||||
);
|
updated_at: new Date().toISOString(),
|
||||||
const result = await response.json();
|
};
|
||||||
expect(result).toBeDefined();
|
const response = await request
|
||||||
// The AI service is mocked in unit tests, but in integration it might be live.
|
.post('/api/ai/plan-trip')
|
||||||
// For now, we just check that we get a text response.
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
expect(result.text).toBeTypeOf('string');
|
.send({ items: [], store: mockStore, userLocation: mockLocation });
|
||||||
|
// The service for this endpoint is disabled and throws an error, which results in a 500.
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const errorResult = response.body;
|
||||||
|
expect(errorResult.message).toContain('planTripWithMaps');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/generate-image should reject because it is not implemented', async () => {
|
it('POST /api/ai/generate-image should reject because it is not implemented', async () => {
|
||||||
// The backend for this is not stubbed and will throw an error.
|
// The backend for this is not stubbed and will throw an error.
|
||||||
// This test confirms that the endpoint is protected and responds as expected to a failure.
|
// This test confirms that the endpoint is protected and responds as expected to a failure.
|
||||||
const response = await aiApiClient.generateImageFromText('a test prompt', undefined, authToken);
|
const response = await request
|
||||||
expect(response.ok).toBe(false);
|
.post('/api/ai/generate-image')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({ prompt: 'a test prompt' });
|
||||||
expect(response.status).toBe(501);
|
expect(response.status).toBe(501);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/generate-speech should reject because it is not implemented', async () => {
|
it('POST /api/ai/generate-speech should reject because it is not implemented', async () => {
|
||||||
// The backend for this is not stubbed and will throw an error.
|
// The backend for this is not stubbed and will throw an error.
|
||||||
const response = await aiApiClient.generateSpeechFromText(
|
const response = await request
|
||||||
'a test prompt',
|
.post('/api/ai/generate-speech')
|
||||||
undefined,
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
authToken,
|
.send({ text: 'a test prompt' });
|
||||||
);
|
|
||||||
expect(response.ok).toBe(false);
|
|
||||||
expect(response.status).toBe(501);
|
expect(response.status).toBe(501);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/tests/integration/auth.integration.test.ts
|
// src/tests/integration/auth.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { loginUser } from '../../services/apiClient';
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
@@ -9,6 +10,8 @@ import type { UserProfile } from '../../types';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const request = supertest(app);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These are integration tests that verify the authentication flow against a running backend server.
|
* These are integration tests that verify the authentication flow against a running backend server.
|
||||||
* Make sure your Express server is running before executing these tests.
|
* Make sure your Express server is running before executing these tests.
|
||||||
@@ -16,30 +19,6 @@ import type { UserProfile } from '../../types';
|
|||||||
* To run only these tests: `vitest run src/tests/auth.integration.test.ts`
|
* To run only these tests: `vitest run src/tests/auth.integration.test.ts`
|
||||||
*/
|
*/
|
||||||
describe('Authentication API Integration', () => {
|
describe('Authentication API Integration', () => {
|
||||||
// --- START DEBUG LOGGING ---
|
|
||||||
// Query the DB from within the test file to see its state.
|
|
||||||
getPool()
|
|
||||||
.query(
|
|
||||||
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
console.log('\n--- [auth.integration.test.ts] Users found in DB from TEST perspective: ---');
|
|
||||||
console.table(res.rows);
|
|
||||||
console.log('--------------------------------------------------------------------------\n');
|
|
||||||
})
|
|
||||||
.catch((err) => console.error('--- [auth.integration.test.ts] DB QUERY FAILED ---', err));
|
|
||||||
// --- END DEBUG LOGGING ---
|
|
||||||
|
|
||||||
// --- START DEBUG LOGGING ---
|
|
||||||
// Log the database connection details as seen by an individual TEST FILE.
|
|
||||||
console.log('\n\n--- [AUTH.INTEGRATION.TEST LOG] DATABASE CONNECTION ---');
|
|
||||||
console.log(` Host: ${process.env.DB_HOST}`);
|
|
||||||
console.log(` Port: ${process.env.DB_PORT}`);
|
|
||||||
console.log(` User: ${process.env.DB_USER}`);
|
|
||||||
console.log(` Database: ${process.env.DB_NAME}`);
|
|
||||||
console.log('-----------------------------------------------------\n');
|
|
||||||
// --- END DEBUG LOGGING ---
|
|
||||||
|
|
||||||
let testUserEmail: string;
|
let testUserEmail: string;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
|
|
||||||
@@ -57,11 +36,14 @@ describe('Authentication API Integration', () => {
|
|||||||
// This test migrates the logic from the old DevTestRunner.tsx component.
|
// This test migrates the logic from the old DevTestRunner.tsx component.
|
||||||
it('should successfully log in a registered user', async () => {
|
it('should successfully log in a registered user', async () => {
|
||||||
// The `rememberMe` parameter is required. For a test, `false` is a safe default.
|
// The `rememberMe` parameter is required. For a test, `false` is a safe default.
|
||||||
const response = await loginUser(testUserEmail, TEST_PASSWORD, false);
|
const response = await request
|
||||||
const data = await response.json();
|
.post('/api/auth/login')
|
||||||
|
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: false });
|
||||||
|
const data = response.body;
|
||||||
|
|
||||||
// Assert that the API returns the expected structure
|
// Assert that the API returns the expected structure
|
||||||
expect(data).toBeDefined();
|
expect(data).toBeDefined();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
expect(data.userprofile).toBeDefined();
|
expect(data.userprofile).toBeDefined();
|
||||||
expect(data.userprofile.user.email).toBe(testUserEmail);
|
expect(data.userprofile.user.email).toBe(testUserEmail);
|
||||||
expect(data.userprofile.user.user_id).toBeTypeOf('string');
|
expect(data.userprofile.user.user_id).toBeTypeOf('string');
|
||||||
@@ -74,9 +56,11 @@ describe('Authentication API Integration', () => {
|
|||||||
const wrongPassword = 'wrongpassword';
|
const wrongPassword = 'wrongpassword';
|
||||||
|
|
||||||
// The loginUser function returns a Response object. We check its status.
|
// The loginUser function returns a Response object. We check its status.
|
||||||
const response = await loginUser(adminEmail, wrongPassword, false);
|
const response = await request
|
||||||
expect(response.ok).toBe(false);
|
.post('/api/auth/login')
|
||||||
const errorData = await response.json();
|
.send({ email: adminEmail, password: wrongPassword, rememberMe: false });
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const errorData = response.body;
|
||||||
expect(errorData.message).toBe('Incorrect email or password.');
|
expect(errorData.message).toBe('Incorrect email or password.');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,9 +69,11 @@ describe('Authentication API Integration', () => {
|
|||||||
const anyPassword = 'any-password';
|
const anyPassword = 'any-password';
|
||||||
|
|
||||||
// The loginUser function returns a Response object. We check its status.
|
// The loginUser function returns a Response object. We check its status.
|
||||||
const response = await loginUser(nonExistentEmail, anyPassword, false);
|
const response = await request
|
||||||
expect(response.ok).toBe(false);
|
.post('/api/auth/login')
|
||||||
const errorData = await response.json();
|
.send({ email: nonExistentEmail, password: anyPassword, rememberMe: false });
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const errorData = response.body;
|
||||||
// Security best practice: the error message should be identical for wrong password and wrong email
|
// Security best practice: the error message should be identical for wrong password and wrong email
|
||||||
// to prevent user enumeration attacks.
|
// to prevent user enumeration attacks.
|
||||||
expect(errorData.message).toBe('Incorrect email or password.');
|
expect(errorData.message).toBe('Incorrect email or password.');
|
||||||
@@ -96,24 +82,21 @@ describe('Authentication API Integration', () => {
|
|||||||
it('should successfully refresh an access token using a refresh token cookie', async () => {
|
it('should successfully refresh an access token using a refresh token cookie', async () => {
|
||||||
// Arrange: Log in to get a fresh, valid refresh token cookie for this specific test.
|
// Arrange: Log in to get a fresh, valid refresh token cookie for this specific test.
|
||||||
// This ensures the test is self-contained and not affected by other tests.
|
// This ensures the test is self-contained and not affected by other tests.
|
||||||
const loginResponse = await loginUser(testUserEmail, TEST_PASSWORD, true);
|
const loginResponse = await request
|
||||||
const setCookieHeader = loginResponse.headers.get('set-cookie');
|
.post('/api/auth/login')
|
||||||
const refreshTokenCookie = setCookieHeader?.split(';')[0];
|
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: true });
|
||||||
|
const refreshTokenCookie = loginResponse.headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
expect(refreshTokenCookie).toBeDefined();
|
expect(refreshTokenCookie).toBeDefined();
|
||||||
|
|
||||||
// Act: Make a request to the refresh-token endpoint, including the cookie.
|
// Act: Make a request to the refresh-token endpoint, including the cookie.
|
||||||
const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
const response = await request
|
||||||
const response = await fetch(`${apiUrl}/auth/refresh-token`, {
|
.post('/api/auth/refresh-token')
|
||||||
method: 'POST',
|
.set('Cookie', refreshTokenCookie!);
|
||||||
headers: {
|
|
||||||
Cookie: refreshTokenCookie!,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assert: Check for a successful response and a new access token.
|
// Assert: Check for a successful response and a new access token.
|
||||||
expect(response.ok).toBe(true);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = response.body;
|
||||||
expect(data.token).toBeTypeOf('string');
|
expect(data.token).toBeTypeOf('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,40 +105,30 @@ describe('Authentication API Integration', () => {
|
|||||||
const invalidRefreshTokenCookie = 'refreshToken=this-is-not-a-valid-token';
|
const invalidRefreshTokenCookie = 'refreshToken=this-is-not-a-valid-token';
|
||||||
|
|
||||||
// Act: Make a request to the refresh-token endpoint with the invalid cookie.
|
// Act: Make a request to the refresh-token endpoint with the invalid cookie.
|
||||||
const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
const response = await request
|
||||||
const response = await fetch(`${apiUrl}/auth/refresh-token`, {
|
.post('/api/auth/refresh-token')
|
||||||
method: 'POST',
|
.set('Cookie', invalidRefreshTokenCookie);
|
||||||
headers: {
|
|
||||||
Cookie: invalidRefreshTokenCookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assert: Check for a 403 Forbidden response.
|
// Assert: Check for a 403 Forbidden response.
|
||||||
expect(response.ok).toBe(false);
|
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
const data = await response.json();
|
const data = response.body;
|
||||||
expect(data.message).toBe('Invalid or expired refresh token.');
|
expect(data.message).toBe('Invalid or expired refresh token.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully log out and clear the refresh token cookie', async () => {
|
it('should successfully log out and clear the refresh token cookie', async () => {
|
||||||
// Arrange: Log in to get a valid refresh token cookie.
|
// Arrange: Log in to get a valid refresh token cookie.
|
||||||
const loginResponse = await loginUser(testUserEmail, TEST_PASSWORD, true);
|
const loginResponse = await request
|
||||||
const setCookieHeader = loginResponse.headers.get('set-cookie');
|
.post('/api/auth/login')
|
||||||
const refreshTokenCookie = setCookieHeader?.split(';')[0];
|
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: true });
|
||||||
|
const refreshTokenCookie = loginResponse.headers['set-cookie'][0].split(';')[0];
|
||||||
expect(refreshTokenCookie).toBeDefined();
|
expect(refreshTokenCookie).toBeDefined();
|
||||||
|
|
||||||
// Act: Make a request to the new logout endpoint, including the cookie.
|
// Act: Make a request to the new logout endpoint, including the cookie.
|
||||||
const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
const response = await request.post('/api/auth/logout').set('Cookie', refreshTokenCookie!);
|
||||||
const response = await fetch(`${apiUrl}/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Cookie: refreshTokenCookie!,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assert: Check for a successful response and a cookie-clearing header.
|
// Assert: Check for a successful response and a cookie-clearing header.
|
||||||
expect(response.ok).toBe(true);
|
expect(response.status).toBe(200);
|
||||||
const logoutSetCookieHeader = response.headers.get('set-cookie');
|
const logoutSetCookieHeader = response.headers['set-cookie'][0];
|
||||||
expect(logoutSetCookieHeader).toContain('refreshToken=;');
|
expect(logoutSetCookieHeader).toContain('refreshToken=;');
|
||||||
expect(logoutSetCookieHeader).toContain('Max-Age=0');
|
expect(logoutSetCookieHeader).toContain('Max-Age=0');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// src/tests/integration/flyer-processing.integration.test.ts
|
// src/tests/integration/flyer-processing.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as aiApiClient from '../../services/aiApiClient';
|
|
||||||
import * as db from '../../services/db/index.db';
|
import * as db from '../../services/db/index.db';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { generateFileChecksum } from '../../utils/checksum';
|
import { generateFileChecksum } from '../../utils/checksum';
|
||||||
@@ -14,6 +15,8 @@ import { createAndLoginUser } from '../utils/testHelpers';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const request = supertest(app);
|
||||||
|
|
||||||
describe('Flyer Processing Background Job Integration Test', () => {
|
describe('Flyer Processing Background Job Integration Test', () => {
|
||||||
const createdUserIds: string[] = [];
|
const createdUserIds: string[] = [];
|
||||||
const createdFlyerIds: number[] = [];
|
const createdFlyerIds: number[] = [];
|
||||||
@@ -60,12 +63,23 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
// Arrange: Load a mock flyer PDF.
|
// Arrange: Load a mock flyer PDF.
|
||||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||||
const imageBuffer = await fs.readFile(imagePath);
|
const imageBuffer = await fs.readFile(imagePath);
|
||||||
const mockImageFile = new File([imageBuffer], 'test-flyer-image.jpg', { type: 'image/jpeg' });
|
// Create a unique buffer and filename for each test run to ensure a unique checksum.
|
||||||
|
// This prevents a 409 Conflict error when the second test runs.
|
||||||
|
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]);
|
||||||
|
const uniqueFileName = `test-flyer-image-${Date.now()}.jpg`;
|
||||||
|
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||||
const checksum = await generateFileChecksum(mockImageFile);
|
const checksum = await generateFileChecksum(mockImageFile);
|
||||||
|
|
||||||
// Act 1: Upload the file to start the background job.
|
// Act 1: Upload the file to start the background job.
|
||||||
const uploadResponse = await aiApiClient.uploadAndProcessFlyer(mockImageFile, checksum, token);
|
const uploadReq = request
|
||||||
const { jobId } = await uploadResponse.json();
|
.post('/api/ai/upload-and-process')
|
||||||
|
.field('checksum', checksum)
|
||||||
|
.attach('flyerFile', uniqueContent, uniqueFileName);
|
||||||
|
if (token) {
|
||||||
|
uploadReq.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
const uploadResponse = await uploadReq;
|
||||||
|
const { jobId } = uploadResponse.body;
|
||||||
|
|
||||||
// Assert 1: Check that a job ID was returned.
|
// Assert 1: Check that a job ID was returned.
|
||||||
expect(jobId).toBeTypeOf('string');
|
expect(jobId).toBeTypeOf('string');
|
||||||
@@ -75,8 +89,12 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
const maxRetries = 20; // Poll for up to 60 seconds (20 * 3s)
|
const maxRetries = 20; // Poll for up to 60 seconds (20 * 3s)
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
||||||
const statusResponse = await aiApiClient.getJobStatus(jobId, token);
|
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
||||||
jobStatus = await statusResponse.json();
|
if (token) {
|
||||||
|
statusReq.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
const statusResponse = await statusReq;
|
||||||
|
jobStatus = statusResponse.body;
|
||||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// src/tests/integration/flyer.integration.test.ts
|
// src/tests/integration/flyer.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import supertest from 'supertest';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
|
import app from '../../../server';
|
||||||
import type { Flyer, FlyerItem } from '../../types';
|
import type { Flyer, FlyerItem } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +11,8 @@ import type { Flyer, FlyerItem } from '../../types';
|
|||||||
|
|
||||||
describe('Public Flyer API Routes Integration Tests', () => {
|
describe('Public Flyer API Routes Integration Tests', () => {
|
||||||
let flyers: Flyer[] = [];
|
let flyers: Flyer[] = [];
|
||||||
|
// Use a supertest instance for all requests in this file
|
||||||
|
const request = supertest(app);
|
||||||
let createdFlyerId: number;
|
let createdFlyerId: number;
|
||||||
|
|
||||||
// Fetch flyers once before all tests in this suite to use in subsequent tests.
|
// Fetch flyers once before all tests in this suite to use in subsequent tests.
|
||||||
@@ -34,18 +37,16 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
[createdFlyerId],
|
[createdFlyerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await apiClient.fetchFlyers();
|
const response = await request.get('/api/flyers');
|
||||||
flyers = await response.json();
|
flyers = response.body;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/flyers', () => {
|
describe('GET /api/flyers', () => {
|
||||||
it('should return a list of flyers', async () => {
|
it('should return a list of flyers', async () => {
|
||||||
// Act: Call the API endpoint using the client function.
|
// Act: Call the API endpoint using the client function.
|
||||||
const response = await apiClient.fetchFlyers();
|
const response = await request.get('/api/flyers');
|
||||||
const flyers: Flyer[] = await response.json();
|
const flyers: Flyer[] = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
// Assert: Verify the response is successful and contains the expected data structure.
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
expect(flyers).toBeInstanceOf(Array);
|
expect(flyers).toBeInstanceOf(Array);
|
||||||
|
|
||||||
// We created a flyer in beforeAll, so we expect the array not to be empty.
|
// We created a flyer in beforeAll, so we expect the array not to be empty.
|
||||||
@@ -69,11 +70,10 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
const testFlyer = flyers[0];
|
const testFlyer = flyers[0];
|
||||||
|
|
||||||
// Act: Fetch items for the first flyer.
|
// Act: Fetch items for the first flyer.
|
||||||
const response = await apiClient.fetchFlyerItems(testFlyer.flyer_id);
|
const response = await request.get(`/api/flyers/${testFlyer.flyer_id}/items`);
|
||||||
const items: FlyerItem[] = await response.json();
|
const items: FlyerItem[] = response.body;
|
||||||
|
|
||||||
// Assert: Verify the response and data structure.
|
expect(response.status).toBe(200);
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
expect(items).toBeInstanceOf(Array);
|
expect(items).toBeInstanceOf(Array);
|
||||||
|
|
||||||
// If there are items, check the shape of the first one.
|
// If there are items, check the shape of the first one.
|
||||||
@@ -87,18 +87,16 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/flyer-items/batch-fetch', () => {
|
describe('POST /api/flyers/items/batch-fetch', () => {
|
||||||
it('should return items for multiple flyer IDs', async () => {
|
it('should return items for multiple flyer IDs', async () => {
|
||||||
// Arrange: Get IDs from the flyers fetched in beforeAll.
|
// Arrange: Get IDs from the flyers fetched in beforeAll.
|
||||||
const flyerIds = flyers.map((f) => f.flyer_id);
|
const flyerIds = flyers.map((f) => f.flyer_id);
|
||||||
expect(flyerIds.length).toBeGreaterThan(0);
|
expect(flyerIds.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Act: Fetch items for all available flyers.
|
// Act: Fetch items for all available flyers.
|
||||||
const response = await apiClient.fetchFlyerItemsForFlyers(flyerIds);
|
const response = await request.post('/api/flyers/items/batch-fetch').send({ flyerIds });
|
||||||
const items: FlyerItem[] = await response.json();
|
const items: FlyerItem[] = response.body;
|
||||||
|
expect(response.status).toBe(200);
|
||||||
// Assert
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
expect(items).toBeInstanceOf(Array);
|
expect(items).toBeInstanceOf(Array);
|
||||||
// The total number of items should be greater than or equal to the number of flyers (assuming at least one item per flyer).
|
// The total number of items should be greater than or equal to the number of flyers (assuming at least one item per flyer).
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
@@ -107,15 +105,15 @@ describe('Public Flyer API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/flyer-items/batch-count', () => {
|
describe('POST /api/flyers/items/batch-count', () => {
|
||||||
it('should return the total count of items for multiple flyer IDs', async () => {
|
it('should return the total count of items for multiple flyer IDs', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const flyerIds = flyers.map((f) => f.flyer_id);
|
const flyerIds = flyers.map((f) => f.flyer_id);
|
||||||
expect(flyerIds.length).toBeGreaterThan(0);
|
expect(flyerIds.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await apiClient.countFlyerItemsForFlyers(flyerIds);
|
const response = await request.post('/api/flyers/items/batch-count').send({ flyerIds });
|
||||||
const result = await response.json();
|
const result = response.body;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.count).toBeTypeOf('number');
|
expect(result.count).toBeTypeOf('number');
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
// src/tests/integration/public.integration.test.ts
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import * as apiClient from '../../services/apiClient';
|
|
||||||
import { getPool } from '../../services/db/connection.db';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @vitest-environment node
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('Public API Routes Integration Tests', () => {
|
|
||||||
let createdFlyerId: number;
|
|
||||||
let createdMasterItemId: number;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const pool = getPool();
|
|
||||||
// Create a store for the flyer
|
|
||||||
const storeRes = await pool.query(
|
|
||||||
`INSERT INTO public.stores (name) VALUES ('Public Test Store') RETURNING store_id`,
|
|
||||||
);
|
|
||||||
const storeId = storeRes.rows[0].store_id;
|
|
||||||
|
|
||||||
// Create a flyer
|
|
||||||
const flyerRes = await pool.query(
|
|
||||||
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
|
|
||||||
VALUES ($1, 'public-test.jpg', 'http://test.com/public.jpg', 0, $2) RETURNING flyer_id`,
|
|
||||||
[storeId, `checksum-public-${Date.now()}`],
|
|
||||||
);
|
|
||||||
createdFlyerId = flyerRes.rows[0].flyer_id;
|
|
||||||
|
|
||||||
// Create a master item. Assumes a category with ID 1 exists from static seeds.
|
|
||||||
const masterItemRes = await pool.query(
|
|
||||||
`INSERT INTO public.master_grocery_items (name, category_id) VALUES ('Public Test Item', 1) RETURNING master_grocery_item_id`,
|
|
||||||
);
|
|
||||||
createdMasterItemId = masterItemRes.rows[0].master_grocery_item_id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const pool = getPool();
|
|
||||||
// Cleanup in reverse order of creation
|
|
||||||
if (createdMasterItemId) {
|
|
||||||
await pool.query(
|
|
||||||
'DELETE FROM public.master_grocery_items WHERE master_grocery_item_id = $1',
|
|
||||||
[createdMasterItemId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (createdFlyerId) {
|
|
||||||
await pool.query('DELETE FROM public.flyers WHERE flyer_id = $1', [createdFlyerId]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Health Check Endpoints', () => {
|
|
||||||
it('GET /api/health/ping should return "pong"', async () => {
|
|
||||||
const response = await apiClient.pingBackend();
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
expect(await response.text()).toBe('pong');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/health/db-schema should return success', async () => {
|
|
||||||
const response = await apiClient.checkDbSchema();
|
|
||||||
const result = await response.json();
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toBe('All required database tables exist.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/health/storage should return success', async () => {
|
|
||||||
// This assumes the STORAGE_PATH is correctly set up for the test environment
|
|
||||||
const response = await apiClient.checkStorage();
|
|
||||||
const result = await response.json();
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('is accessible and writable');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/health/db-pool should return success', async () => {
|
|
||||||
const response = await apiClient.checkDbPoolHealth();
|
|
||||||
// The pingBackend function returns a boolean directly, so no .json() call is needed.
|
|
||||||
// However, checkDbPoolHealth returns a Response, so we need to parse it.
|
|
||||||
const result = await response.json();
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).toContain('Pool Status:');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Public Data Endpoints', () => {
|
|
||||||
it('GET /api/flyers should return a list of flyers', async () => {
|
|
||||||
const response = await apiClient.fetchFlyers();
|
|
||||||
const flyers = await response.json();
|
|
||||||
expect(flyers).toBeInstanceOf(Array);
|
|
||||||
// We created a flyer, so we expect it to be in the list.
|
|
||||||
expect(flyers.length).toBeGreaterThan(0);
|
|
||||||
const foundFlyer = flyers.find((f: { flyer_id: number }) => f.flyer_id === createdFlyerId);
|
|
||||||
expect(foundFlyer).toBeDefined();
|
|
||||||
expect(foundFlyer).toHaveProperty('store');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/master-items should return a list of master items', async () => {
|
|
||||||
const response = await apiClient.fetchMasterItems();
|
|
||||||
const masterItems = await response.json();
|
|
||||||
expect(masterItems).toBeInstanceOf(Array);
|
|
||||||
// We created a master item, so we expect it to be in the list.
|
|
||||||
expect(masterItems.length).toBeGreaterThan(0);
|
|
||||||
const foundItem = masterItems.find(
|
|
||||||
(i: { master_grocery_item_id: number }) => i.master_grocery_item_id === createdMasterItemId,
|
|
||||||
);
|
|
||||||
expect(foundItem).toBeDefined();
|
|
||||||
expect(foundItem).toHaveProperty('category_name');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/tests/integration/public.routes.integration.test.ts
|
// src/tests/integration/public.routes.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
import type {
|
import type {
|
||||||
Flyer,
|
Flyer,
|
||||||
FlyerItem,
|
FlyerItem,
|
||||||
@@ -13,8 +14,11 @@ import type {
|
|||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { createAndLoginUser } from '../utils/testHelpers';
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
|
|
||||||
const API_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
/**
|
||||||
const request = supertest(API_URL.replace('/api', '')); // supertest needs the server's base URL
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
|
||||||
|
const request = supertest(app);
|
||||||
|
|
||||||
describe('Public API Routes Integration Tests', () => {
|
describe('Public API Routes Integration Tests', () => {
|
||||||
// Shared state for tests
|
// Shared state for tests
|
||||||
@@ -30,7 +34,7 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
// which also handles activity logging correctly.
|
// which also handles activity logging correctly.
|
||||||
const { user: createdUser } = await createAndLoginUser({
|
const { user: createdUser } = await createAndLoginUser({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
password: 'test-hash',
|
password: 'a-Very-Strong-Password-123!',
|
||||||
fullName: 'Public Routes Test User',
|
fullName: 'Public Routes Test User',
|
||||||
});
|
});
|
||||||
testUser = createdUser;
|
testUser = createdUser;
|
||||||
@@ -54,9 +58,10 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
testFlyer = flyerRes.rows[0];
|
testFlyer = flyerRes.rows[0];
|
||||||
|
|
||||||
// Add an item to the flyer
|
// Add an item to the flyer
|
||||||
await pool.query(`INSERT INTO public.flyer_items (flyer_id, item) VALUES ($1, 'Test Item')`, [
|
await pool.query(
|
||||||
testFlyer.flyer_id,
|
`INSERT INTO public.flyer_items (flyer_id, item, price_display, quantity) VALUES ($1, 'Test Item', '$0.00', 'each')`,
|
||||||
]);
|
[testFlyer.flyer_id],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -96,17 +101,17 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.success).toBe(true);
|
expect(response.body.success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Public Data Endpoints', () => {
|
it('GET /api/health/time should return the server time', async () => {
|
||||||
it('GET /api/time should return the server time', async () => {
|
const response = await request.get('/api/health/time');
|
||||||
const response = await request.get('/api/time');
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toHaveProperty('currentTime');
|
expect(response.body).toHaveProperty('currentTime');
|
||||||
expect(response.body).toHaveProperty('year');
|
expect(response.body).toHaveProperty('year');
|
||||||
expect(response.body).toHaveProperty('week');
|
expect(response.body).toHaveProperty('week');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Public Data Endpoints', () => {
|
||||||
it('GET /api/flyers should return a list of flyers', async () => {
|
it('GET /api/flyers should return a list of flyers', async () => {
|
||||||
const response = await request.get('/api/flyers');
|
const response = await request.get('/api/flyers');
|
||||||
const flyers: Flyer[] = response.body;
|
const flyers: Flyer[] = response.body;
|
||||||
@@ -125,25 +130,25 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
expect(items[0].flyer_id).toBe(testFlyer.flyer_id);
|
expect(items[0].flyer_id).toBe(testFlyer.flyer_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/flyer-items/batch-fetch should return items for multiple flyers', async () => {
|
it('POST /api/flyers/items/batch-fetch should return items for multiple flyers', async () => {
|
||||||
const flyerIds = [testFlyer.flyer_id];
|
const flyerIds = [testFlyer.flyer_id];
|
||||||
const response = await request.post('/api/flyer-items/batch-fetch').send({ flyerIds });
|
const response = await request.post('/api/flyers/items/batch-fetch').send({ flyerIds });
|
||||||
const items: FlyerItem[] = response.body;
|
const items: FlyerItem[] = response.body;
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(items).toBeInstanceOf(Array);
|
expect(items).toBeInstanceOf(Array);
|
||||||
expect(items.length).toBeGreaterThan(0);
|
expect(items.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/flyer-items/batch-count should return a count for multiple flyers', async () => {
|
it('POST /api/flyers/items/batch-count should return a count for multiple flyers', async () => {
|
||||||
const flyerIds = [testFlyer.flyer_id];
|
const flyerIds = [testFlyer.flyer_id];
|
||||||
const response = await request.post('/api/flyer-items/batch-count').send({ flyerIds });
|
const response = await request.post('/api/flyers/items/batch-count').send({ flyerIds });
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.count).toBeTypeOf('number');
|
expect(response.body.count).toBeTypeOf('number');
|
||||||
expect(response.body.count).toBeGreaterThan(0);
|
expect(response.body.count).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/master-items should return a list of master grocery items', async () => {
|
it('GET /api/personalization/master-items should return a list of master grocery items', async () => {
|
||||||
const response = await request.get('/api/master-items');
|
const response = await request.get('/api/personalization/master-items');
|
||||||
const masterItems = response.body;
|
const masterItems = response.body;
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(masterItems).toBeInstanceOf(Array);
|
expect(masterItems).toBeInstanceOf(Array);
|
||||||
@@ -189,9 +194,9 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
expect(items).toBeInstanceOf(Array);
|
expect(items).toBeInstanceOf(Array);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/dietary-restrictions should return a list of restrictions', async () => {
|
it('GET /api/personalization/dietary-restrictions should return a list of restrictions', async () => {
|
||||||
// This test relies on static seed data for a lookup table, which is acceptable.
|
// This test relies on static seed data for a lookup table, which is acceptable.
|
||||||
const response = await request.get('/api/dietary-restrictions');
|
const response = await request.get('/api/personalization/dietary-restrictions');
|
||||||
const restrictions: DietaryRestriction[] = response.body;
|
const restrictions: DietaryRestriction[] = response.body;
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(restrictions).toBeInstanceOf(Array);
|
expect(restrictions).toBeInstanceOf(Array);
|
||||||
@@ -199,8 +204,8 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
expect(restrictions[0]).toHaveProperty('dietary_restriction_id');
|
expect(restrictions[0]).toHaveProperty('dietary_restriction_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/appliances should return a list of appliances', async () => {
|
it('GET /api/personalization/appliances should return a list of appliances', async () => {
|
||||||
const response = await request.get('/api/appliances');
|
const response = await request.get('/api/personalization/appliances');
|
||||||
const appliances: Appliance[] = response.body;
|
const appliances: Appliance[] = response.body;
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(appliances).toBeInstanceOf(Array);
|
expect(appliances).toBeInstanceOf(Array);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/tests/integration/system.integration.test.ts
|
// src/tests/integration/system.integration.test.ts
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
@@ -9,15 +10,16 @@ import * as apiClient from '../../services/apiClient';
|
|||||||
describe('System API Routes Integration Tests', () => {
|
describe('System API Routes Integration Tests', () => {
|
||||||
describe('GET /api/system/pm2-status', () => {
|
describe('GET /api/system/pm2-status', () => {
|
||||||
it('should return a status for PM2', async () => {
|
it('should return a status for PM2', async () => {
|
||||||
|
const request = supertest(app);
|
||||||
// In a typical CI environment without PM2, this will fail gracefully.
|
// In a typical CI environment without PM2, this will fail gracefully.
|
||||||
// The test verifies that the endpoint responds correctly, even if PM2 isn't running.
|
// The test verifies that the endpoint responds correctly, even if PM2 isn't running.
|
||||||
const response = await apiClient.checkPm2Status();
|
const response = await request.get('/api/system/pm2-status');
|
||||||
const result = await response.json();
|
const result = response.body;
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result).toHaveProperty('message');
|
expect(result).toHaveProperty('message');
|
||||||
// If the response is successful (200 OK), it must have a 'success' property.
|
// If the response is successful (200 OK), it must have a 'success' property.
|
||||||
// If it's an error (e.g., 500 because pm2 command not found), it will only have 'message'.
|
// If it's an error (e.g., 500 because pm2 command not found), it will only have 'message'.
|
||||||
if (response.ok) {
|
if (response.status === 200) {
|
||||||
expect(result).toHaveProperty('success');
|
expect(result).toHaveProperty('success');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/tests/integration/user.integration.test.ts
|
// src/tests/integration/user.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import * as apiClient from '../../services/apiClient';
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import type { UserProfile, MasterGroceryItem, ShoppingList } from '../../types';
|
import type { UserProfile, MasterGroceryItem, ShoppingList } from '../../types';
|
||||||
@@ -10,25 +11,12 @@ import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const request = supertest(app);
|
||||||
|
|
||||||
describe('User API Routes Integration Tests', () => {
|
describe('User API Routes Integration Tests', () => {
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
|
|
||||||
// --- START DEBUG LOGGING ---
|
|
||||||
// Query the DB from within the test file to see its state.
|
|
||||||
beforeAll(async () => {
|
|
||||||
const res = await getPool().query(
|
|
||||||
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'\n--- [user.integration.test.ts] Users found in DB from TEST perspective (beforeAll): ---',
|
|
||||||
);
|
|
||||||
console.table(res.rows);
|
|
||||||
console.log(
|
|
||||||
'-------------------------------------------------------------------------------------\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
// --- END DEBUG LOGGING ---
|
|
||||||
// Before any tests run, create a new user and log them in.
|
// Before any tests run, create a new user and log them in.
|
||||||
// The token will be used for all subsequent API calls in this test suite.
|
// The token will be used for all subsequent API calls in this test suite.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -62,11 +50,13 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should fetch the authenticated user profile via GET /api/users/profile', async () => {
|
it('should fetch the authenticated user profile via GET /api/users/profile', async () => {
|
||||||
// Act: Call the API endpoint using the authenticated token.
|
// Act: Call the API endpoint using the authenticated token.
|
||||||
const response = await apiClient.getAuthenticatedUserProfile({ tokenOverride: authToken });
|
const response = await request
|
||||||
const profile = await response.json();
|
.get('/api/users/profile')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
const profile = response.body;
|
||||||
|
|
||||||
// Assert: Verify the profile data matches the created user.
|
// Assert: Verify the profile data matches the created user.
|
||||||
expect(profile).toBeDefined();
|
expect(response.status).toBe(200);
|
||||||
expect(profile.user.user_id).toBe(testUser.user.user_id);
|
expect(profile.user.user_id).toBe(testUser.user.user_id);
|
||||||
expect(profile.user.email).toBe(testUser.user.email); // This was already correct
|
expect(profile.user.email).toBe(testUser.user.email); // This was already correct
|
||||||
expect(profile.full_name).toBe('Test User');
|
expect(profile.full_name).toBe('Test User');
|
||||||
@@ -80,20 +70,21 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act: Call the update endpoint with the new data and the auth token.
|
// Act: Call the update endpoint with the new data and the auth token.
|
||||||
const response = await apiClient.updateUserProfile(profileUpdates, {
|
const response = await request
|
||||||
tokenOverride: authToken,
|
.put('/api/users/profile')
|
||||||
});
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
const updatedProfile = await response.json();
|
.send(profileUpdates);
|
||||||
|
const updatedProfile = response.body;
|
||||||
|
|
||||||
// Assert: Check that the returned profile reflects the changes.
|
// Assert: Check that the returned profile reflects the changes.
|
||||||
expect(updatedProfile).toBeDefined();
|
expect(response.status).toBe(200);
|
||||||
expect(updatedProfile.full_name).toBe('Updated Test User');
|
expect(updatedProfile.full_name).toBe('Updated Test User');
|
||||||
|
|
||||||
// Also, fetch the profile again to ensure the change was persisted.
|
// Also, fetch the profile again to ensure the change was persisted.
|
||||||
const refetchResponse = await apiClient.getAuthenticatedUserProfile({
|
const refetchResponse = await request
|
||||||
tokenOverride: authToken,
|
.get('/api/users/profile')
|
||||||
});
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
const refetchedProfile = await refetchResponse.json();
|
const refetchedProfile = refetchResponse.body;
|
||||||
expect(refetchedProfile.full_name).toBe('Updated Test User');
|
expect(refetchedProfile.full_name).toBe('Updated Test User');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,14 +95,14 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act: Call the update endpoint.
|
// Act: Call the update endpoint.
|
||||||
const response = await apiClient.updateUserPreferences(preferenceUpdates, {
|
const response = await request
|
||||||
tokenOverride: authToken,
|
.put('/api/users/profile/preferences')
|
||||||
});
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
const updatedProfile = await response.json();
|
.send(preferenceUpdates);
|
||||||
|
const updatedProfile = response.body;
|
||||||
|
|
||||||
// Assert: Check that the preferences object in the returned profile is updated.
|
// Assert: Check that the preferences object in the returned profile is updated.
|
||||||
expect(updatedProfile).toBeDefined();
|
expect(response.status).toBe(200);
|
||||||
expect(updatedProfile.preferences).toBeDefined();
|
|
||||||
expect(updatedProfile.preferences?.darkMode).toBe(true);
|
expect(updatedProfile.preferences?.darkMode).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,9 +113,14 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
// Act & Assert: Attempt to register and expect the promise to reject
|
// Act & Assert: Attempt to register and expect the promise to reject
|
||||||
// with an error message indicating the password is too weak.
|
// with an error message indicating the password is too weak.
|
||||||
const response = await apiClient.registerUser(email, weakPassword, 'Weak Password User');
|
const response = await request.post('/api/auth/register').send({
|
||||||
expect(response.ok).toBe(false);
|
email,
|
||||||
const errorData = (await response.json()) as { message: string; errors: { message: string }[] };
|
password: weakPassword,
|
||||||
|
full_name: 'Weak Password User',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const errorData = response.body as { message: string; errors: { message: string }[] };
|
||||||
// For validation errors, the detailed messages are in the `errors` array.
|
// For validation errors, the detailed messages are in the `errors` array.
|
||||||
// We join them to check for the specific feedback from the password strength checker.
|
// We join them to check for the specific feedback from the password strength checker.
|
||||||
const detailedErrorMessage = errorData.errors?.map((e) => e.message).join(' ');
|
const detailedErrorMessage = errorData.errors?.map((e) => e.message).join(' ');
|
||||||
@@ -137,18 +133,22 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
const { token: deletionToken } = await createAndLoginUser({ email: deletionEmail });
|
const { token: deletionToken } = await createAndLoginUser({ email: deletionEmail });
|
||||||
|
|
||||||
// Act: Call the delete endpoint with the correct password and token.
|
// Act: Call the delete endpoint with the correct password and token.
|
||||||
const response = await apiClient.deleteUserAccount(TEST_PASSWORD, {
|
const response = await request
|
||||||
tokenOverride: deletionToken,
|
.delete('/api/users/account')
|
||||||
});
|
.set('Authorization', `Bearer ${deletionToken}`)
|
||||||
const deleteResponse = await response.json();
|
.send({ password: TEST_PASSWORD });
|
||||||
|
const deleteResponse = response.body;
|
||||||
|
|
||||||
// Assert: Check for a successful deletion message.
|
// Assert: Check for a successful deletion message.
|
||||||
|
expect(response.status).toBe(200);
|
||||||
expect(deleteResponse.message).toBe('Account deleted successfully.');
|
expect(deleteResponse.message).toBe('Account deleted successfully.');
|
||||||
|
|
||||||
// Assert (Verification): Attempting to log in again with the same credentials should now fail.
|
// Assert (Verification): Attempting to log in again with the same credentials should now fail.
|
||||||
const loginResponse = await apiClient.loginUser(deletionEmail, TEST_PASSWORD, false);
|
const loginResponse = await request
|
||||||
expect(loginResponse.ok).toBe(false);
|
.post('/api/auth/login')
|
||||||
const errorData = await loginResponse.json();
|
.send({ email: deletionEmail, password: TEST_PASSWORD });
|
||||||
|
expect(loginResponse.status).toBe(401);
|
||||||
|
const errorData = loginResponse.body;
|
||||||
expect(errorData.message).toBe('Incorrect email or password.');
|
expect(errorData.message).toBe('Incorrect email or password.');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,12 +158,14 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
const { user: resetUser } = await createAndLoginUser({ email: resetEmail });
|
const { user: resetUser } = await createAndLoginUser({ email: resetEmail });
|
||||||
|
|
||||||
// Act 1: Request a password reset. In our test environment, the token is returned in the response.
|
// Act 1: Request a password reset. In our test environment, the token is returned in the response.
|
||||||
const resetRequestRawResponse = await apiClient.requestPasswordReset(resetEmail);
|
const resetRequestRawResponse = await request
|
||||||
if (!resetRequestRawResponse.ok) {
|
.post('/api/auth/forgot-password')
|
||||||
const errorData = await resetRequestRawResponse.json();
|
.send({ email: resetEmail });
|
||||||
|
if (resetRequestRawResponse.status !== 200) {
|
||||||
|
const errorData = resetRequestRawResponse.body;
|
||||||
throw new Error(errorData.message || 'Password reset request failed');
|
throw new Error(errorData.message || 'Password reset request failed');
|
||||||
}
|
}
|
||||||
const resetRequestResponse = await resetRequestRawResponse.json();
|
const resetRequestResponse = resetRequestRawResponse.body;
|
||||||
const resetToken = resetRequestResponse.token;
|
const resetToken = resetRequestResponse.token;
|
||||||
|
|
||||||
// Assert 1: Check that we received a token.
|
// Assert 1: Check that we received a token.
|
||||||
@@ -172,19 +174,23 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
// Act 2: Use the token to set a new password.
|
// Act 2: Use the token to set a new password.
|
||||||
const newPassword = 'my-new-secure-password-!@#$';
|
const newPassword = 'my-new-secure-password-!@#$';
|
||||||
const resetRawResponse = await apiClient.resetPassword(resetToken!, newPassword);
|
const resetRawResponse = await request
|
||||||
if (!resetRawResponse.ok) {
|
.post('/api/auth/reset-password')
|
||||||
const errorData = await resetRawResponse.json();
|
.send({ token: resetToken!, newPassword });
|
||||||
|
if (resetRawResponse.status !== 200) {
|
||||||
|
const errorData = resetRawResponse.body;
|
||||||
throw new Error(errorData.message || 'Password reset failed');
|
throw new Error(errorData.message || 'Password reset failed');
|
||||||
}
|
}
|
||||||
const resetResponse = await resetRawResponse.json();
|
const resetResponse = resetRawResponse.body;
|
||||||
|
|
||||||
// Assert 2: Check for a successful password reset message.
|
// Assert 2: Check for a successful password reset message.
|
||||||
expect(resetResponse.message).toBe('Password has been reset successfully.');
|
expect(resetResponse.message).toBe('Password has been reset successfully.');
|
||||||
|
|
||||||
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
|
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
|
||||||
const loginResponse = await apiClient.loginUser(resetEmail, newPassword, false);
|
const loginResponse = await request
|
||||||
const loginData = await loginResponse.json();
|
.post('/api/auth/login')
|
||||||
|
.send({ email: resetEmail, password: newPassword });
|
||||||
|
const loginData = loginResponse.body;
|
||||||
expect(loginData.userprofile).toBeDefined();
|
expect(loginData.userprofile).toBeDefined();
|
||||||
expect(loginData.userprofile.user.user_id).toBe(resetUser.user.user_id);
|
expect(loginData.userprofile.user.user_id).toBe(resetUser.user.user_id);
|
||||||
});
|
});
|
||||||
@@ -192,20 +198,21 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
|
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
|
||||||
it('should allow a user to add and remove a watched item', async () => {
|
it('should allow a user to add and remove a watched item', async () => {
|
||||||
// Act 1: Add a new watched item. The API returns the created master item.
|
// Act 1: Add a new watched item. The API returns the created master item.
|
||||||
const addResponse = await apiClient.addWatchedItem(
|
const addResponse = await request
|
||||||
'Integration Test Item',
|
.post('/api/users/watched-items')
|
||||||
'Other/Miscellaneous',
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
authToken,
|
.send({ itemName: 'Integration Test Item', category: 'Other/Miscellaneous' });
|
||||||
);
|
const newItem = addResponse.body;
|
||||||
const newItem = await addResponse.json();
|
|
||||||
|
|
||||||
// Assert 1: Check that the item was created correctly.
|
// Assert 1: Check that the item was created correctly.
|
||||||
expect(newItem).toBeDefined();
|
expect(addResponse.status).toBe(201);
|
||||||
expect(newItem.name).toBe('Integration Test Item');
|
expect(newItem.name).toBe('Integration Test Item');
|
||||||
|
|
||||||
// Act 2: Fetch all watched items for the user.
|
// Act 2: Fetch all watched items for the user.
|
||||||
const watchedItemsResponse = await apiClient.fetchWatchedItems(authToken);
|
const watchedItemsResponse = await request
|
||||||
const watchedItems = await watchedItemsResponse.json();
|
.get('/api/users/watched-items')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
const watchedItems = watchedItemsResponse.body;
|
||||||
|
|
||||||
// Assert 2: Verify the new item is in the user's watched list.
|
// Assert 2: Verify the new item is in the user's watched list.
|
||||||
expect(
|
expect(
|
||||||
@@ -216,11 +223,16 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
// Act 3: Remove the watched item.
|
// Act 3: Remove the watched item.
|
||||||
await apiClient.removeWatchedItem(newItem.master_grocery_item_id, authToken);
|
const removeResponse = await request
|
||||||
|
.delete(`/api/users/watched-items/${newItem.master_grocery_item_id}`)
|
||||||
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
expect(removeResponse.status).toBe(204);
|
||||||
|
|
||||||
// Assert 3: Fetch again and verify the item is gone.
|
// Assert 3: Fetch again and verify the item is gone.
|
||||||
const finalWatchedItemsResponse = await apiClient.fetchWatchedItems(authToken);
|
const finalWatchedItemsResponse = await request
|
||||||
const finalWatchedItems = await finalWatchedItemsResponse.json();
|
.get('/api/users/watched-items')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
const finalWatchedItems = finalWatchedItemsResponse.body;
|
||||||
expect(
|
expect(
|
||||||
finalWatchedItems.some(
|
finalWatchedItems.some(
|
||||||
(item: MasterGroceryItem) =>
|
(item: MasterGroceryItem) =>
|
||||||
@@ -231,31 +243,33 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should allow a user to manage a shopping list', async () => {
|
it('should allow a user to manage a shopping list', async () => {
|
||||||
// Act 1: Create a new shopping list.
|
// Act 1: Create a new shopping list.
|
||||||
const createListResponse = await apiClient.createShoppingList(
|
const createListResponse = await request
|
||||||
'My Integration Test List',
|
.post('/api/users/shopping-lists')
|
||||||
authToken,
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
);
|
.send({ name: 'My Integration Test List' });
|
||||||
const newList = await createListResponse.json();
|
const newList = createListResponse.body;
|
||||||
|
|
||||||
// Assert 1: Check that the list was created.
|
// Assert 1: Check that the list was created.
|
||||||
expect(newList).toBeDefined();
|
expect(createListResponse.status).toBe(201);
|
||||||
expect(newList.name).toBe('My Integration Test List');
|
expect(newList.name).toBe('My Integration Test List');
|
||||||
|
|
||||||
// Act 2: Add an item to the new list.
|
// Act 2: Add an item to the new list.
|
||||||
const addItemResponse = await apiClient.addShoppingListItem(
|
const addItemResponse = await request
|
||||||
newList.shopping_list_id,
|
.post(`/api/users/shopping-lists/${newList.shopping_list_id}/items`)
|
||||||
{ customItemName: 'Custom Test Item' },
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
authToken,
|
.send({ customItemName: 'Custom Test Item' });
|
||||||
);
|
const addedItem = addItemResponse.body;
|
||||||
const addedItem = await addItemResponse.json();
|
|
||||||
|
|
||||||
// Assert 2: Check that the item was added.
|
// Assert 2: Check that the item was added.
|
||||||
expect(addedItem).toBeDefined();
|
expect(addItemResponse.status).toBe(201);
|
||||||
expect(addedItem.custom_item_name).toBe('Custom Test Item');
|
expect(addedItem.custom_item_name).toBe('Custom Test Item');
|
||||||
|
|
||||||
// Assert 3: Fetch all lists and verify the new item is present in the correct list.
|
// Assert 3: Fetch all lists and verify the new item is present in the correct list.
|
||||||
const fetchResponse = await apiClient.fetchShoppingLists(authToken);
|
const fetchResponse = await request
|
||||||
const lists = await fetchResponse.json();
|
.get('/api/users/shopping-lists')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`);
|
||||||
|
const lists = fetchResponse.body;
|
||||||
|
expect(fetchResponse.status).toBe(200);
|
||||||
const updatedList = lists.find(
|
const updatedList = lists.find(
|
||||||
(l: ShoppingList) => l.shopping_list_id === newList.shopping_list_id,
|
(l: ShoppingList) => l.shopping_list_id === newList.shopping_list_id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,30 @@
|
|||||||
// src/tests/integration/user.routes.integration.test.ts
|
// src/tests/integration/user.routes.integration.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
|
import app from '../../../server';
|
||||||
import { getPool } from '../../services/db/connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import type { UserProfile } from '../../types';
|
import type { UserProfile } from '../../types';
|
||||||
|
import { createAndLoginUser } from '../utils/testHelpers';
|
||||||
|
|
||||||
const API_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
/**
|
||||||
const request = supertest(API_URL.replace('/api', '')); // supertest needs the server's base URL
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
|
||||||
|
const request = supertest(app);
|
||||||
|
|
||||||
let authToken = '';
|
let authToken = '';
|
||||||
let createdListId: number;
|
let createdListId: number;
|
||||||
let testUser: UserProfile;
|
let testUser: UserProfile;
|
||||||
const testPassword = 'password-for-user-routes-test';
|
|
||||||
|
|
||||||
describe('User Routes Integration Tests (/api/users)', () => {
|
describe('User Routes Integration Tests (/api/users)', () => {
|
||||||
// Authenticate once before all tests in this suite to get a JWT.
|
// Authenticate once before all tests in this suite to get a JWT.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Create a new user for this test suite to avoid dependency on seeded data
|
// Use the helper to create and log in a user in one step.
|
||||||
const testEmail = `user-routes-test-${Date.now()}@example.com`;
|
const { user, token } = await createAndLoginUser({
|
||||||
|
fullName: 'User Routes Test User',
|
||||||
// 1. Register the user
|
});
|
||||||
const registerResponse = await request
|
testUser = user;
|
||||||
.post('/api/auth/register')
|
authToken = token;
|
||||||
.send({ email: testEmail, password: testPassword, full_name: 'User Routes Test User' });
|
|
||||||
expect(registerResponse.status).toBe(201);
|
|
||||||
|
|
||||||
// 2. Log in as the new user
|
|
||||||
const loginResponse = await request
|
|
||||||
.post('/api/auth/login')
|
|
||||||
.send({ email: testEmail, password: testPassword });
|
|
||||||
|
|
||||||
if (loginResponse.status !== 200) {
|
|
||||||
console.error('Login failed in beforeAll hook:', loginResponse.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(loginResponse.status).toBe(200);
|
|
||||||
expect(loginResponse.body.token).toBeDefined();
|
|
||||||
authToken = loginResponse.body.token;
|
|
||||||
testUser = loginResponse.body.userprofile;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
36
src/types.ts
36
src/types.ts
@@ -52,7 +52,10 @@ export type FlyerDbInsert = Omit<FlyerInsert, 'store_name'> & { store_id: number
|
|||||||
* Represents the data required to insert a new flyer item into the database.
|
* Represents the data required to insert a new flyer item into the database.
|
||||||
* It's a subset of the full FlyerItem type.
|
* It's a subset of the full FlyerItem type.
|
||||||
*/
|
*/
|
||||||
export type FlyerItemInsert = Omit<FlyerItem, 'flyer_item_id' | 'flyer_id' | 'created_at' | 'updated_at'>;
|
export type FlyerItemInsert = Omit<
|
||||||
|
FlyerItem,
|
||||||
|
'flyer_item_id' | 'flyer_id' | 'created_at' | 'updated_at'
|
||||||
|
>;
|
||||||
|
|
||||||
export interface UnitPrice {
|
export interface UnitPrice {
|
||||||
value: number;
|
value: number;
|
||||||
@@ -163,7 +166,6 @@ export interface Profile {
|
|||||||
updated_by?: string | null;
|
updated_by?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the combined user and profile data object returned by the backend's /users/profile endpoint.
|
* Represents the combined user and profile data object returned by the backend's /users/profile endpoint.
|
||||||
* It embeds the User object within the Profile object.
|
* It embeds the User object within the Profile object.
|
||||||
@@ -325,7 +327,6 @@ export interface RecipeIngredientSubstitution {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
tag_id: number;
|
tag_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -718,7 +719,10 @@ export type AiAnalysisAction =
|
|||||||
// Dispatched when an analysis that returns a simple string succeeds.
|
// Dispatched when an analysis that returns a simple string succeeds.
|
||||||
| { type: 'FETCH_SUCCESS_TEXT'; payload: { analysisType: AnalysisType; data: string } }
|
| { type: 'FETCH_SUCCESS_TEXT'; payload: { analysisType: AnalysisType; data: string } }
|
||||||
// Dispatched when an analysis that returns text and sources succeeds.
|
// Dispatched when an analysis that returns text and sources succeeds.
|
||||||
| { type: 'FETCH_SUCCESS_GROUNDED'; payload: { analysisType: AnalysisType; data: GroundedResponse } }
|
| {
|
||||||
|
type: 'FETCH_SUCCESS_GROUNDED';
|
||||||
|
payload: { analysisType: AnalysisType; data: GroundedResponse };
|
||||||
|
}
|
||||||
// Dispatched when the image generation succeeds.
|
// Dispatched when the image generation succeeds.
|
||||||
| { type: 'FETCH_SUCCESS_IMAGE'; payload: { data: string } }
|
| { type: 'FETCH_SUCCESS_IMAGE'; payload: { data: string } }
|
||||||
// Dispatched when any analysis fails.
|
// Dispatched when any analysis fails.
|
||||||
@@ -738,11 +742,25 @@ export interface ProcessingStage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CATEGORIES = [
|
export const CATEGORIES = [
|
||||||
'Fruits & Vegetables', 'Meat & Seafood', 'Dairy & Eggs', 'Bakery & Bread',
|
'Fruits & Vegetables',
|
||||||
'Pantry & Dry Goods', 'Beverages', 'Frozen Foods', 'Snacks', 'Household & Cleaning',
|
'Meat & Seafood',
|
||||||
'Personal Care & Health', 'Baby & Child', 'Pet Supplies', 'Deli & Prepared Foods',
|
'Dairy & Eggs',
|
||||||
'Canned Goods', 'Condiments & Spices', 'Breakfast & Cereal', 'Organic',
|
'Bakery & Bread',
|
||||||
'International Foods', 'Other/Miscellaneous'
|
'Pantry & Dry Goods',
|
||||||
|
'Beverages',
|
||||||
|
'Frozen Foods',
|
||||||
|
'Snacks',
|
||||||
|
'Household & Cleaning',
|
||||||
|
'Personal Care & Health',
|
||||||
|
'Baby & Child',
|
||||||
|
'Pet Supplies',
|
||||||
|
'Deli & Prepared Foods',
|
||||||
|
'Canned Goods',
|
||||||
|
'Condiments & Spices',
|
||||||
|
'Breakfast & Cereal',
|
||||||
|
'Organic',
|
||||||
|
'International Foods',
|
||||||
|
'Other/Miscellaneous',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,8 +4,5 @@ console.log('--- [EXECUTION PROOF] tailwind.config.js is being loaded. ---');
|
|||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
@@ -17,9 +17,7 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
// This line makes Vitest's global APIs (describe, it, expect) available everywhere
|
// This line makes Vitest's global APIs (describe, it, expect) available everywhere
|
||||||
// without needing to import them.
|
// without needing to import them.
|
||||||
"types": [
|
"types": ["vitest/globals"]
|
||||||
"vitest/globals"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
// This is the most important part: It tells TypeScript to include ALL files
|
// This is the most important part: It tells TypeScript to include ALL files
|
||||||
// within the 'src' directory, including your new 'vite-env.d.ts' file.
|
// within the 'src' directory, including your new 'vite-env.d.ts' file.
|
||||||
|
|||||||
@@ -9,5 +9,10 @@
|
|||||||
"strict": true, // It's good practice to keep tooling config strict
|
"strict": true, // It's good practice to keep tooling config strict
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts", "vitest.config.ts", "vitest.config.integration.ts", "vitest.workspace.ts"]
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.integration.ts",
|
||||||
|
"vitest.workspace.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ export default defineConfig({
|
|||||||
'**/node_modules/**',
|
'**/node_modules/**',
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'src/tests/integration/**', // Exclude the entire integration test directory
|
'src/tests/integration/**', // Exclude the entire integration test directory
|
||||||
'**/*.e2e.test.ts'
|
'**/*.e2e.test.ts',
|
||||||
],
|
],
|
||||||
// Disable file parallelism to run tests sequentially (replaces --no-threads)
|
// Disable file parallelism to run tests sequentially (replaces --no-threads)
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
@@ -60,7 +60,9 @@ export default defineConfig({
|
|||||||
reporter: [
|
reporter: [
|
||||||
// Add maxCols to suggest a wider output for the text summary.
|
// Add maxCols to suggest a wider output for the text summary.
|
||||||
['text', { maxCols: 200 }],
|
['text', { maxCols: 200 }],
|
||||||
'html', 'json'],
|
'html',
|
||||||
|
'json',
|
||||||
|
],
|
||||||
// hanging-process reporter helps identify tests that do not exit properly - comes at a high cost tho
|
// hanging-process reporter helps identify tests that do not exit properly - comes at a high cost tho
|
||||||
//reporter: ['verbose', 'html', 'json', 'hanging-process'],
|
//reporter: ['verbose', 'html', 'json', 'hanging-process'],
|
||||||
reportsDirectory: './.coverage/unit',
|
reportsDirectory: './.coverage/unit',
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ console.error('[DEBUG] Base vite config keys:', Object.keys(baseViteConfig));
|
|||||||
* It MERGES with the main vite.config.ts to inherit plugins and aliases,
|
* It MERGES with the main vite.config.ts to inherit plugins and aliases,
|
||||||
* then overrides the test-specific settings for a Node.js environment.
|
* then overrides the test-specific settings for a Node.js environment.
|
||||||
*/
|
*/
|
||||||
const finalConfig = mergeConfig(baseViteConfig, defineConfig({
|
const finalConfig = mergeConfig(
|
||||||
|
baseViteConfig,
|
||||||
|
defineConfig({
|
||||||
test: {
|
test: {
|
||||||
// Override settings from the main config for this specific test project.
|
// Override settings from the main config for this specific test project.
|
||||||
name: 'integration',
|
name: 'integration',
|
||||||
@@ -57,8 +59,9 @@ const finalConfig = mergeConfig(baseViteConfig, defineConfig({
|
|||||||
reportOnFailure: true, // This ensures the report generates even if tests fail
|
reportOnFailure: true, // This ensures the report generates even if tests fail
|
||||||
clean: true,
|
clean: true,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
console.error('[DEBUG] Integration Final Config - INCLUDE:', finalConfig.test?.include);
|
console.error('[DEBUG] Integration Final Config - INCLUDE:', finalConfig.test?.include);
|
||||||
console.error('[DEBUG] Integration Final Config - EXCLUDE:', finalConfig.test?.exclude);
|
console.error('[DEBUG] Integration Final Config - EXCLUDE:', finalConfig.test?.exclude);
|
||||||
|
|||||||
@@ -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}'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,10 @@
|
|||||||
*/
|
*/
|
||||||
export default [
|
export default [
|
||||||
// DEBUGGING LOG
|
// DEBUGGING LOG
|
||||||
((): string => { console.error('\n[DEBUG] Loading vitest.workspace.ts'); return ''; })(),
|
((): string => {
|
||||||
|
console.error('\n[DEBUG] Loading vitest.workspace.ts');
|
||||||
|
return '';
|
||||||
|
})(),
|
||||||
'vite.config.ts', // Defines the 'unit' test project
|
'vite.config.ts', // Defines the 'unit' test project
|
||||||
'vitest.config.integration.ts', // Defines the 'integration' test project
|
'vitest.config.integration.ts', // Defines the 'integration' test project
|
||||||
];
|
];
|
||||||
Reference in New Issue
Block a user