Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87d75d0571 | ||
| faf2900c28 | |||
|
|
5258efc179 | ||
| 2a5cc5bb51 | |||
|
|
8eaee2844f | ||
| 440a19c3a7 | |||
| 4ae6d84240 |
@@ -88,7 +88,10 @@
|
|||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(\"/c/Users/games3/.local/bin/uvx.exe\" markitdown-mcp --help)",
|
"Bash(\"/c/Users/games3/.local/bin/uvx.exe\" markitdown-mcp --help)",
|
||||||
"Bash(git stash:*)",
|
"Bash(git stash:*)",
|
||||||
"Bash(ping:*)"
|
"Bash(ping:*)",
|
||||||
|
"Bash(tee:*)",
|
||||||
|
"Bash(timeout 1800 podman exec flyer-crawler-dev npm run test:unit:*)",
|
||||||
|
"mcp__filesystem__edit_file"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,21 +71,6 @@ GRANT ALL PRIVILEGES ON DATABASE flyer_crawler TO flyer_crawler;
|
|||||||
\q
|
\q
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create Bugsink Database (for error tracking)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u postgres psql
|
|
||||||
```
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Create dedicated Bugsink user and database
|
|
||||||
CREATE USER bugsink WITH PASSWORD 'BUGSINK_SECURE_PASSWORD';
|
|
||||||
CREATE DATABASE bugsink OWNER bugsink;
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE bugsink TO bugsink;
|
|
||||||
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configure PostgreSQL for Remote Access (if needed)
|
### Configure PostgreSQL for Remote Access (if needed)
|
||||||
|
|
||||||
Edit `/etc/postgresql/14/main/postgresql.conf`:
|
Edit `/etc/postgresql/14/main/postgresql.conf`:
|
||||||
@@ -343,115 +328,324 @@ sudo systemctl enable nginx
|
|||||||
|
|
||||||
## Bugsink Error Tracking
|
## Bugsink Error Tracking
|
||||||
|
|
||||||
Bugsink is a lightweight, self-hosted Sentry-compatible error tracking system. See [ADR-015](adr/0015-application-performance-monitoring-and-error-tracking.md) for architecture details.
|
Bugsink is a lightweight, self-hosted Sentry-compatible error tracking system. This guide follows the [official Bugsink single-server production setup](https://www.bugsink.com/docs/single-server-production/).
|
||||||
|
|
||||||
### Install Bugsink
|
See [ADR-015](adr/0015-application-performance-monitoring-and-error-tracking.md) for architecture details.
|
||||||
|
|
||||||
|
### Step 1: Create Bugsink User
|
||||||
|
|
||||||
|
Create a dedicated non-root user for Bugsink:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create virtual environment
|
sudo adduser bugsink --disabled-password --gecos ""
|
||||||
sudo mkdir -p /opt/bugsink
|
|
||||||
sudo python3 -m venv /opt/bugsink/venv
|
|
||||||
|
|
||||||
# Activate and install
|
|
||||||
source /opt/bugsink/venv/bin/activate
|
|
||||||
pip install bugsink
|
|
||||||
|
|
||||||
# Create wrapper scripts
|
|
||||||
sudo tee /opt/bugsink/bin/bugsink-manage << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
source /opt/bugsink/venv/bin/activate
|
|
||||||
exec python -m bugsink.manage "$@"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo tee /opt/bugsink/bin/bugsink-runserver << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
source /opt/bugsink/venv/bin/activate
|
|
||||||
exec python -m bugsink.runserver "$@"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo chmod +x /opt/bugsink/bin/bugsink-manage /opt/bugsink/bin/bugsink-runserver
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure Bugsink
|
### Step 2: Set Up Virtual Environment and Install Bugsink
|
||||||
|
|
||||||
Create `/etc/bugsink/environment`:
|
Switch to the bugsink user:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /etc/bugsink
|
sudo su - bugsink
|
||||||
sudo nano /etc/bugsink/environment
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Create the virtual environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SECRET_KEY=YOUR_RANDOM_50_CHAR_SECRET_KEY
|
python3 -m venv venv
|
||||||
DATABASE_URL=postgresql://bugsink:BUGSINK_SECURE_PASSWORD@localhost:5432/bugsink
|
|
||||||
BASE_URL=http://localhost:8000
|
|
||||||
PORT=8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Activate the virtual environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo chmod 600 /etc/bugsink/environment
|
source venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
### Initialize Bugsink Database
|
You should see `(venv)` at the beginning of your prompt. Now install Bugsink:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source /etc/bugsink/environment
|
pip install bugsink --upgrade
|
||||||
/opt/bugsink/bin/bugsink-manage migrate
|
bugsink-show-version
|
||||||
/opt/bugsink/bin/bugsink-manage migrate --database=snappea
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create Bugsink Admin User
|
You should see output like `bugsink 2.x.x`.
|
||||||
|
|
||||||
|
### Step 3: Create Configuration File
|
||||||
|
|
||||||
|
Generate the configuration file. Replace `bugsink.yourdomain.com` with your actual hostname:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/bugsink/bin/bugsink-manage createsuperuser
|
bugsink-create-conf --template=singleserver --host=bugsink.yourdomain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create Systemd Service
|
This creates `bugsink_conf.py` in `/home/bugsink/`. Edit it to customize settings:
|
||||||
|
|
||||||
Create `/etc/systemd/system/bugsink.service`:
|
```bash
|
||||||
|
nano bugsink_conf.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key settings to review:**
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
| ------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| `BASE_URL` | The URL where Bugsink will be accessed (e.g., `https://bugsink.yourdomain.com`) |
|
||||||
|
| `SITE_TITLE` | Display name for your Bugsink instance |
|
||||||
|
| `SECRET_KEY` | Auto-generated, but verify it exists |
|
||||||
|
| `TIME_ZONE` | Your timezone (e.g., `America/New_York`) |
|
||||||
|
| `USER_REGISTRATION` | Set to `"closed"` to disable public signup |
|
||||||
|
| `SINGLE_USER` | Set to `True` if only one user will use this instance |
|
||||||
|
|
||||||
|
### Step 4: Initialize Database
|
||||||
|
|
||||||
|
Bugsink uses SQLite by default, which is recommended for single-server setups. Run the database migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bugsink-manage migrate
|
||||||
|
bugsink-manage migrate snappea --database=snappea
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the database files were created:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls *.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `db.sqlite3` and `snappea.sqlite3`.
|
||||||
|
|
||||||
|
### Step 5: Create Admin User
|
||||||
|
|
||||||
|
Create the superuser account. Using your email as the username is recommended:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bugsink-manage createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Save these credentials - you'll need them to log into the Bugsink web UI.
|
||||||
|
|
||||||
|
### Step 6: Verify Configuration
|
||||||
|
|
||||||
|
Run Django's deployment checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bugsink-manage check_migrations
|
||||||
|
bugsink-manage check --deploy --fail-level WARNING
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit back to root for the next steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Create Gunicorn Service
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/gunicorn-bugsink.service`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/gunicorn-bugsink.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the following content:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Bugsink Error Tracking
|
Description=Gunicorn daemon for Bugsink
|
||||||
After=network.target postgresql.service
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
|
||||||
User=www-data
|
|
||||||
Group=www-data
|
|
||||||
EnvironmentFile=/etc/bugsink/environment
|
|
||||||
ExecStart=/opt/bugsink/bin/bugsink-runserver 0.0.0.0:8000
|
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
Type=notify
|
||||||
|
User=bugsink
|
||||||
|
Group=bugsink
|
||||||
|
|
||||||
|
Environment="PYTHONUNBUFFERED=1"
|
||||||
|
WorkingDirectory=/home/bugsink
|
||||||
|
ExecStart=/home/bugsink/venv/bin/gunicorn \
|
||||||
|
--bind="127.0.0.1:8000" \
|
||||||
|
--workers=4 \
|
||||||
|
--timeout=6 \
|
||||||
|
--access-logfile - \
|
||||||
|
--max-requests=1000 \
|
||||||
|
--max-requests-jitter=100 \
|
||||||
|
bugsink.wsgi
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=5
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Enable and start the service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable bugsink
|
sudo systemctl enable --now gunicorn-bugsink.service
|
||||||
sudo systemctl start bugsink
|
sudo systemctl status gunicorn-bugsink.service
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create Bugsink Projects and Get DSNs
|
Test that Gunicorn is responding (replace hostname):
|
||||||
|
|
||||||
1. Access Bugsink UI at `http://localhost:8000`
|
```bash
|
||||||
2. Log in with admin credentials
|
curl http://localhost:8000/accounts/login/ --header "Host: bugsink.yourdomain.com"
|
||||||
3. Create projects:
|
```
|
||||||
|
|
||||||
|
You should see HTML output containing a login form.
|
||||||
|
|
||||||
|
### Step 8: Create Snappea Background Worker Service
|
||||||
|
|
||||||
|
Snappea is Bugsink's background task processor. Create `/etc/systemd/system/snappea.service`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/snappea.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the following content:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Snappea daemon for Bugsink background tasks
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
User=bugsink
|
||||||
|
Group=bugsink
|
||||||
|
|
||||||
|
Environment="PYTHONUNBUFFERED=1"
|
||||||
|
WorkingDirectory=/home/bugsink
|
||||||
|
ExecStart=/home/bugsink/venv/bin/bugsink-runsnappea
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=5
|
||||||
|
RuntimeMaxSec=1d
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and start the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now snappea.service
|
||||||
|
sudo systemctl status snappea.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify snappea is working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo su - bugsink
|
||||||
|
source venv/bin/activate
|
||||||
|
bugsink-manage checksnappea
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Configure NGINX for Bugsink
|
||||||
|
|
||||||
|
Create `/etc/nginx/sites-available/bugsink`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/nginx/sites-available/bugsink
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the following (replace `bugsink.yourdomain.com` with your hostname):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name bugsink.yourdomain.com;
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/bugsink.access.log;
|
||||||
|
error_log /var/log/nginx/bugsink.error.log;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/bugsink /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 10: Configure SSL with Certbot (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d bugsink.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
After SSL is configured, update the NGINX config to add security headers. Edit `/etc/nginx/sites-available/bugsink` and add to the `location /` block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; preload" always;
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload NGINX:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 11: Create Projects and Get DSNs
|
||||||
|
|
||||||
|
1. Access Bugsink UI at `https://bugsink.yourdomain.com`
|
||||||
|
2. Log in with the admin credentials you created
|
||||||
|
3. Create a new team (or use the default)
|
||||||
|
4. Create projects:
|
||||||
- **flyer-crawler-backend** (Platform: Node.js)
|
- **flyer-crawler-backend** (Platform: Node.js)
|
||||||
- **flyer-crawler-frontend** (Platform: React)
|
- **flyer-crawler-frontend** (Platform: JavaScript/React)
|
||||||
4. Copy the DSNs from each project's settings
|
5. For each project, go to Settings → Client Keys (DSN)
|
||||||
5. Update `/etc/flyer-crawler/environment` with the DSNs
|
6. Copy the DSN URLs
|
||||||
|
|
||||||
### Test Error Tracking
|
### Step 12: Configure Application to Use Bugsink
|
||||||
|
|
||||||
|
Update `/etc/flyer-crawler/environment` with the DSNs from step 11:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sentry/Bugsink Error Tracking
|
||||||
|
SENTRY_DSN=https://YOUR_BACKEND_KEY@bugsink.yourdomain.com/1
|
||||||
|
VITE_SENTRY_DSN=https://YOUR_FRONTEND_KEY@bugsink.yourdomain.com/2
|
||||||
|
SENTRY_ENVIRONMENT=production
|
||||||
|
VITE_SENTRY_ENVIRONMENT=production
|
||||||
|
SENTRY_ENABLED=true
|
||||||
|
VITE_SENTRY_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the application to pick up the new settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 13: Test Error Tracking
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/flyer-crawler
|
cd /opt/flyer-crawler
|
||||||
npx tsx scripts/test-bugsink.ts
|
npx tsx scripts/test-bugsink.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Check Bugsink UI for test events.
|
Check the Bugsink UI - you should see a test event appear.
|
||||||
|
|
||||||
|
### Bugsink Maintenance Commands
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| View Gunicorn status | `sudo systemctl status gunicorn-bugsink` |
|
||||||
|
| View Snappea status | `sudo systemctl status snappea` |
|
||||||
|
| View Gunicorn logs | `sudo journalctl -u gunicorn-bugsink -f` |
|
||||||
|
| View Snappea logs | `sudo journalctl -u snappea -f` |
|
||||||
|
| Restart Bugsink | `sudo systemctl restart gunicorn-bugsink snappea` |
|
||||||
|
| Run management commands | `sudo su - bugsink` then `source venv/bin/activate && bugsink-manage <command>` |
|
||||||
|
| Upgrade Bugsink | `sudo su - bugsink && source venv/bin/activate && pip install bugsink --upgrade && exit && sudo systemctl restart gunicorn-bugsink snappea` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.92",
|
"version": "0.9.95",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.9.92",
|
"version": "0.9.95",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.14.2",
|
"@bull-board/api": "^6.14.2",
|
||||||
"@bull-board/express": "^6.14.2",
|
"@bull-board/express": "^6.14.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.92",
|
"version": "0.9.95",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
@@ -1360,7 +1360,8 @@ CREATE TRIGGER on_auth_user_created
|
|||||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
-- 2. Create a reusable function to automatically update 'updated_at' columns.
|
-- 2. Create a reusable function to automatically update 'updated_at' columns.
|
||||||
DROP FUNCTION IF EXISTS public.handle_updated_at();
|
-- CASCADE drops dependent triggers; they are recreated by the DO block below
|
||||||
|
DROP FUNCTION IF EXISTS public.handle_updated_at() CASCADE;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.handle_updated_at()
|
CREATE OR REPLACE FUNCTION public.handle_updated_at()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
|
|||||||
@@ -2775,7 +2775,8 @@ CREATE TRIGGER on_auth_user_created
|
|||||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
-- 2. Create a reusable function to automatically update 'updated_at' columns.
|
-- 2. Create a reusable function to automatically update 'updated_at' columns.
|
||||||
DROP FUNCTION IF EXISTS public.handle_updated_at();
|
-- CASCADE drops dependent triggers; they are recreated by the DO block below
|
||||||
|
DROP FUNCTION IF EXISTS public.handle_updated_at() CASCADE;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.handle_updated_at()
|
CREATE OR REPLACE FUNCTION public.handle_updated_at()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ describe('Multer Middleware Directory Creation', () => {
|
|||||||
await import('./multer.middleware');
|
await import('./multer.middleware');
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
// It should try to create both the flyer storage and avatar storage paths
|
// It should try to create the flyer, avatar, and receipt storage paths
|
||||||
expect(mocks.mkdir).toHaveBeenCalledTimes(2);
|
expect(mocks.mkdir).toHaveBeenCalledTimes(3);
|
||||||
expect(mocks.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
expect(mocks.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
||||||
expect(mocks.logger.info).toHaveBeenCalledWith('Ensured multer storage directories exist.');
|
expect(mocks.logger.info).toHaveBeenCalledWith('Ensured multer storage directories exist.');
|
||||||
expect(mocks.logger.error).not.toHaveBeenCalled();
|
expect(mocks.logger.error).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -23,14 +23,21 @@ export const validateRequest =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// On success, merge the parsed (and coerced) data back into the request objects.
|
// On success, merge the parsed (and coerced) data back into the request objects.
|
||||||
// We don't reassign `req.params`, `req.query`, or `req.body` directly, as they
|
// For req.params, we can delete existing keys and assign new ones.
|
||||||
// might be read-only getters in some environments (like during supertest tests).
|
|
||||||
// Instead, we clear the existing object and merge the new properties.
|
|
||||||
Object.keys(req.params).forEach((key) => delete (req.params as ParamsDictionary)[key]);
|
Object.keys(req.params).forEach((key) => delete (req.params as ParamsDictionary)[key]);
|
||||||
Object.assign(req.params, params);
|
Object.assign(req.params, params);
|
||||||
|
|
||||||
Object.keys(req.query).forEach((key) => delete (req.query as Query)[key]);
|
// For req.query in Express 5, the query object is lazily evaluated from the URL
|
||||||
Object.assign(req.query, query);
|
// and cannot be mutated directly. We use Object.defineProperty to replace
|
||||||
|
// the getter with our validated/transformed query object.
|
||||||
|
Object.defineProperty(req, 'query', {
|
||||||
|
value: query as Query,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// For body, direct reassignment works.
|
||||||
req.body = body;
|
req.body = body;
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ vi.mock('../lib/queue', () => ({
|
|||||||
cleanupQueue: {},
|
cleanupQueue: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { mockedDb } = vi.hoisted(() => {
|
const { mockedDb, mockedBrandService } = vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
mockedDb: {
|
mockedDb: {
|
||||||
adminRepo: {
|
adminRepo: {
|
||||||
@@ -59,6 +59,9 @@ const { mockedDb } = vi.hoisted(() => {
|
|||||||
deleteUserById: vi.fn(),
|
deleteUserById: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mockedBrandService: {
|
||||||
|
updateBrandLogo: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +92,26 @@ vi.mock('node:fs/promises', () => ({
|
|||||||
vi.mock('../services/backgroundJobService');
|
vi.mock('../services/backgroundJobService');
|
||||||
vi.mock('../services/geocodingService.server');
|
vi.mock('../services/geocodingService.server');
|
||||||
vi.mock('../services/queueService.server');
|
vi.mock('../services/queueService.server');
|
||||||
|
vi.mock('../services/queues.server');
|
||||||
|
vi.mock('../services/workers.server');
|
||||||
|
vi.mock('../services/monitoringService.server');
|
||||||
|
vi.mock('../services/cacheService.server');
|
||||||
|
vi.mock('../services/userService');
|
||||||
|
vi.mock('../services/brandService', () => ({
|
||||||
|
brandService: mockedBrandService,
|
||||||
|
}));
|
||||||
|
vi.mock('../services/receiptService.server');
|
||||||
|
vi.mock('../services/aiService.server');
|
||||||
|
vi.mock('../config/env', () => ({
|
||||||
|
config: {
|
||||||
|
database: { host: 'localhost', port: 5432, user: 'test', password: 'test', name: 'test' },
|
||||||
|
redis: { url: 'redis://localhost:6379' },
|
||||||
|
auth: { jwtSecret: 'test-secret' },
|
||||||
|
server: { port: 3000, host: 'localhost' },
|
||||||
|
},
|
||||||
|
isAiConfigured: vi.fn().mockReturnValue(false),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock('@bull-board/api'); // Keep this mock for the API part
|
vi.mock('@bull-board/api'); // Keep this mock for the API part
|
||||||
vi.mock('@bull-board/api/bullMQAdapter'); // Keep this mock for the adapter
|
vi.mock('@bull-board/api/bullMQAdapter'); // Keep this mock for the adapter
|
||||||
|
|
||||||
@@ -103,13 +126,17 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', async () => ({
|
vi.mock('../services/logger.server', async () => {
|
||||||
// Use async import to avoid hoisting issues with mockLogger
|
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
return {
|
||||||
}));
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
|
||||||
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
@@ -314,22 +341,23 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
|
|
||||||
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
||||||
const brandId = 55;
|
const brandId = 55;
|
||||||
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
|
const mockLogoUrl = '/flyer-images/brand-logos/test-logo.png';
|
||||||
|
vi.mocked(mockedBrandService.updateBrandLogo).mockResolvedValue(mockLogoUrl);
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post(`/api/admin/brands/${brandId}/logo`)
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.data.message).toBe('Brand logo updated successfully.');
|
expect(response.body.data.message).toBe('Brand logo updated successfully.');
|
||||||
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
|
expect(vi.mocked(mockedBrandService.updateBrandLogo)).toHaveBeenCalledWith(
|
||||||
brandId,
|
brandId,
|
||||||
expect.stringContaining('/flyer-images/'),
|
expect.objectContaining({ fieldname: 'logoImage' }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /brands/:id/logo should return 500 on DB error', async () => {
|
it('POST /brands/:id/logo should return 500 on DB error', async () => {
|
||||||
const brandId = 55;
|
const brandId = 55;
|
||||||
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockRejectedValue(new Error('DB Error'));
|
vi.mocked(mockedBrandService.updateBrandLogo).mockRejectedValue(new Error('DB Error'));
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post(`/api/admin/brands/${brandId}/logo`)
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||||
@@ -347,7 +375,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
it('should clean up the uploaded file if updating the brand logo fails', async () => {
|
it('should clean up the uploaded file if updating the brand logo fails', async () => {
|
||||||
const brandId = 55;
|
const brandId = 55;
|
||||||
const dbError = new Error('DB Connection Failed');
|
const dbError = new Error('DB Connection Failed');
|
||||||
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockRejectedValue(dbError);
|
vi.mocked(mockedBrandService.updateBrandLogo).mockRejectedValue(dbError);
|
||||||
|
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post(`/api/admin/brands/${brandId}/logo`)
|
.post(`/api/admin/brands/${brandId}/logo`)
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ vi.mock('../services/queueService.server', () => ({
|
|||||||
cleanupWorker: {},
|
cleanupWorker: {},
|
||||||
weeklyAnalyticsWorker: {},
|
weeklyAnalyticsWorker: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the monitoring service - the routes use this service for job operations
|
||||||
|
vi.mock('../services/monitoringService.server', () => ({
|
||||||
|
monitoringService: {
|
||||||
|
getWorkerStatuses: vi.fn(),
|
||||||
|
getQueueStatuses: vi.fn(),
|
||||||
|
retryFailedJob: vi.fn(),
|
||||||
|
getJobStatus: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../services/db/index.db', () => ({
|
vi.mock('../services/db/index.db', () => ({
|
||||||
adminRepo: {},
|
adminRepo: {},
|
||||||
flyerRepo: {},
|
flyerRepo: {},
|
||||||
@@ -59,21 +70,22 @@ import adminRouter from './admin.routes';
|
|||||||
|
|
||||||
// Import the mocked modules to control them
|
// Import the mocked modules to control them
|
||||||
import { backgroundJobService } from '../services/backgroundJobService'; // This is now a mock
|
import { backgroundJobService } from '../services/backgroundJobService'; // This is now a mock
|
||||||
import {
|
import { analyticsQueue, cleanupQueue } from '../services/queueService.server';
|
||||||
flyerQueue,
|
import { monitoringService } from '../services/monitoringService.server'; // This is now a mock
|
||||||
analyticsQueue,
|
import { NotFoundError, ValidationError } from '../services/db/errors.db';
|
||||||
cleanupQueue,
|
|
||||||
weeklyAnalyticsQueue,
|
|
||||||
} from '../services/queueService.server';
|
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', async () => ({
|
vi.mock('../services/logger.server', async () => {
|
||||||
// Use async import to avoid hoisting issues with mockLogger
|
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
return {
|
||||||
}));
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
|
||||||
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
@@ -221,13 +233,8 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
const jobId = 'failed-job-1';
|
const jobId = 'failed-job-1';
|
||||||
|
|
||||||
it('should successfully retry a failed job', async () => {
|
it('should successfully retry a failed job', async () => {
|
||||||
// Arrange
|
// Arrange - mock the monitoring service to resolve successfully
|
||||||
const mockJob = {
|
vi.mocked(monitoringService.retryFailedJob).mockResolvedValue(undefined);
|
||||||
id: jobId,
|
|
||||||
getState: vi.fn().mockResolvedValue('failed'),
|
|
||||||
retry: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as unknown as Job);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||||
@@ -237,7 +244,11 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
expect(response.body.data.message).toBe(
|
expect(response.body.data.message).toBe(
|
||||||
`Job ${jobId} has been successfully marked for retry.`,
|
`Job ${jobId} has been successfully marked for retry.`,
|
||||||
);
|
);
|
||||||
expect(mockJob.retry).toHaveBeenCalledTimes(1);
|
expect(monitoringService.retryFailedJob).toHaveBeenCalledWith(
|
||||||
|
queueName,
|
||||||
|
jobId,
|
||||||
|
'admin-user-id',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 if the queue name is invalid', async () => {
|
it('should return 400 if the queue name is invalid', async () => {
|
||||||
@@ -250,8 +261,10 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
const queueName = 'weekly-analytics-reporting';
|
const queueName = 'weekly-analytics-reporting';
|
||||||
const jobId = 'some-job-id';
|
const jobId = 'some-job-id';
|
||||||
|
|
||||||
// Ensure getJob returns undefined (not found)
|
// Mock monitoringService.retryFailedJob to throw NotFoundError
|
||||||
vi.mocked(weeklyAnalyticsQueue.getJob).mockResolvedValue(undefined);
|
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(
|
||||||
|
new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`),
|
||||||
|
);
|
||||||
|
|
||||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||||
|
|
||||||
@@ -262,7 +275,10 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if the job ID is not found in the queue', async () => {
|
it('should return 404 if the job ID is not found in the queue', async () => {
|
||||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
|
// Mock monitoringService.retryFailedJob to throw NotFoundError
|
||||||
|
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(
|
||||||
|
new NotFoundError("Job with ID 'not-found-job' not found in queue 'flyer-processing'."),
|
||||||
|
);
|
||||||
const response = await supertest(app).post(
|
const response = await supertest(app).post(
|
||||||
`/api/admin/jobs/${queueName}/not-found-job/retry`,
|
`/api/admin/jobs/${queueName}/not-found-job/retry`,
|
||||||
);
|
);
|
||||||
@@ -271,12 +287,10 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 if the job is not in a failed state', async () => {
|
it('should return 400 if the job is not in a failed state', async () => {
|
||||||
const mockJob = {
|
// Mock monitoringService.retryFailedJob to throw ValidationError
|
||||||
id: jobId,
|
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(
|
||||||
getState: vi.fn().mockResolvedValue('completed'),
|
new ValidationError([], "Job is not in a 'failed' state. Current state: completed."),
|
||||||
retry: vi.fn(),
|
);
|
||||||
};
|
|
||||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as unknown as Job);
|
|
||||||
|
|
||||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||||
|
|
||||||
@@ -284,16 +298,11 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
expect(response.body.error.message).toBe(
|
expect(response.body.error.message).toBe(
|
||||||
"Job is not in a 'failed' state. Current state: completed.",
|
"Job is not in a 'failed' state. Current state: completed.",
|
||||||
); // This is now handled by the errorHandler
|
); // This is now handled by the errorHandler
|
||||||
expect(mockJob.retry).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if job.retry() throws an error', async () => {
|
it('should return 500 if job.retry() throws an error', async () => {
|
||||||
const mockJob = {
|
// Mock monitoringService.retryFailedJob to throw a generic error
|
||||||
id: jobId,
|
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(new Error('Cannot retry job'));
|
||||||
getState: vi.fn().mockResolvedValue('failed'),
|
|
||||||
retry: vi.fn().mockRejectedValue(new Error('Cannot retry job')),
|
|
||||||
};
|
|
||||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as unknown as Job);
|
|
||||||
|
|
||||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||||
|
|
||||||
|
|||||||
@@ -92,10 +92,12 @@ import { adminRepo } from '../services/db/index.db';
|
|||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', () => ({
|
vi.mock('../services/logger.server', () => ({
|
||||||
logger: mockLogger,
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => mockLogger),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
|
||||||
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ vi.mock('../services/cacheService.server', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../services/logger.server', async () => ({
|
vi.mock('../services/logger.server', async () => {
|
||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
}));
|
return {
|
||||||
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@bull-board/api');
|
vi.mock('@bull-board/api');
|
||||||
vi.mock('@bull-board/api/bullMQAdapter');
|
vi.mock('@bull-board/api/bullMQAdapter');
|
||||||
@@ -57,9 +61,27 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('node:fs/promises');
|
vi.mock('node:fs/promises');
|
||||||
|
vi.mock('../services/queues.server');
|
||||||
|
vi.mock('../services/workers.server');
|
||||||
|
vi.mock('../services/monitoringService.server');
|
||||||
|
vi.mock('../services/userService');
|
||||||
|
vi.mock('../services/brandService');
|
||||||
|
vi.mock('../services/receiptService.server');
|
||||||
|
vi.mock('../services/aiService.server');
|
||||||
|
vi.mock('../config/env', () => ({
|
||||||
|
config: {
|
||||||
|
database: { host: 'localhost', port: 5432, user: 'test', password: 'test', name: 'test' },
|
||||||
|
redis: { url: 'redis://localhost:6379' },
|
||||||
|
auth: { jwtSecret: 'test-secret' },
|
||||||
|
server: { port: 3000, host: 'localhost' },
|
||||||
|
},
|
||||||
|
isAiConfigured: vi.fn().mockReturnValue(false),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock Passport to allow admin access
|
// Mock Passport to allow admin access
|
||||||
vi.mock('./passport.routes', () => ({
|
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
|
||||||
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
|
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
|
||||||
req.user = createMockUserProfile({ role: 'admin' });
|
req.user = createMockUserProfile({ role: 'admin' });
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ vi.mock('node:fs/promises');
|
|||||||
vi.mock('../services/backgroundJobService');
|
vi.mock('../services/backgroundJobService');
|
||||||
vi.mock('../services/geocodingService.server');
|
vi.mock('../services/geocodingService.server');
|
||||||
vi.mock('../services/queueService.server');
|
vi.mock('../services/queueService.server');
|
||||||
|
vi.mock('../services/queues.server');
|
||||||
|
vi.mock('../services/workers.server');
|
||||||
|
vi.mock('../services/monitoringService.server');
|
||||||
|
vi.mock('../services/cacheService.server');
|
||||||
|
vi.mock('../services/userService');
|
||||||
|
vi.mock('../services/brandService');
|
||||||
|
vi.mock('../services/receiptService.server');
|
||||||
|
vi.mock('../services/aiService.server');
|
||||||
|
vi.mock('../config/env', () => ({
|
||||||
|
config: {
|
||||||
|
database: { host: 'localhost', port: 5432, user: 'test', password: 'test', name: 'test' },
|
||||||
|
redis: { url: 'redis://localhost:6379' },
|
||||||
|
auth: { jwtSecret: 'test-secret' },
|
||||||
|
server: { port: 3000, host: 'localhost' },
|
||||||
|
},
|
||||||
|
isAiConfigured: vi.fn().mockReturnValue(false),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock('@bull-board/api');
|
vi.mock('@bull-board/api');
|
||||||
vi.mock('@bull-board/api/bullMQAdapter');
|
vi.mock('@bull-board/api/bullMQAdapter');
|
||||||
vi.mock('@bull-board/express', () => ({
|
vi.mock('@bull-board/express', () => ({
|
||||||
@@ -44,13 +62,17 @@ import adminRouter from './admin.routes';
|
|||||||
import { adminRepo } from '../services/db/index.db';
|
import { adminRepo } from '../services/db/index.db';
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', async () => ({
|
vi.mock('../services/logger.server', async () => {
|
||||||
// Use async import to avoid hoisting issues with mockLogger
|
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
return {
|
||||||
}));
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
|
||||||
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ vi.mock('../services/backgroundJobService', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('../services/queueService.server');
|
vi.mock('../services/queueService.server');
|
||||||
|
vi.mock('../services/queues.server');
|
||||||
|
vi.mock('../services/workers.server');
|
||||||
|
vi.mock('../services/monitoringService.server');
|
||||||
|
vi.mock('../services/cacheService.server');
|
||||||
|
vi.mock('../services/userService');
|
||||||
|
vi.mock('../services/brandService');
|
||||||
|
vi.mock('../services/receiptService.server');
|
||||||
|
vi.mock('../services/aiService.server');
|
||||||
|
vi.mock('../config/env', () => ({
|
||||||
|
config: {
|
||||||
|
database: { host: 'localhost', port: 5432, user: 'test', password: 'test', name: 'test' },
|
||||||
|
redis: { url: 'redis://localhost:6379' },
|
||||||
|
auth: { jwtSecret: 'test-secret' },
|
||||||
|
server: { port: 3000, host: 'localhost' },
|
||||||
|
},
|
||||||
|
isAiConfigured: vi.fn().mockReturnValue(false),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock('@bull-board/api');
|
vi.mock('@bull-board/api');
|
||||||
vi.mock('@bull-board/api/bullMQAdapter');
|
vi.mock('@bull-board/api/bullMQAdapter');
|
||||||
vi.mock('@bull-board/express', () => ({
|
vi.mock('@bull-board/express', () => ({
|
||||||
@@ -49,13 +67,17 @@ import adminRouter from './admin.routes';
|
|||||||
import { geocodingService } from '../services/geocodingService.server';
|
import { geocodingService } from '../services/geocodingService.server';
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', async () => ({
|
vi.mock('../services/logger.server', async () => {
|
||||||
// Use async import to avoid hoisting issues with mockLogger
|
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
return {
|
||||||
}));
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
|
||||||
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
req.user = createMockUserProfile({
|
req.user = createMockUserProfile({
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ vi.mock('../services/db/recipe.db');
|
|||||||
vi.mock('../services/backgroundJobService');
|
vi.mock('../services/backgroundJobService');
|
||||||
vi.mock('../services/geocodingService.server');
|
vi.mock('../services/geocodingService.server');
|
||||||
vi.mock('../services/queueService.server');
|
vi.mock('../services/queueService.server');
|
||||||
|
vi.mock('../services/queues.server');
|
||||||
|
vi.mock('../services/workers.server');
|
||||||
|
vi.mock('../services/monitoringService.server');
|
||||||
|
vi.mock('../services/cacheService.server');
|
||||||
|
vi.mock('../services/brandService');
|
||||||
|
vi.mock('../services/receiptService.server');
|
||||||
|
vi.mock('../services/aiService.server');
|
||||||
|
vi.mock('../config/env', () => ({
|
||||||
|
config: {
|
||||||
|
database: { host: 'localhost', port: 5432, user: 'test', password: 'test', name: 'test' },
|
||||||
|
redis: { url: 'redis://localhost:6379' },
|
||||||
|
auth: { jwtSecret: 'test-secret' },
|
||||||
|
server: { port: 3000, host: 'localhost' },
|
||||||
|
},
|
||||||
|
isAiConfigured: vi.fn().mockReturnValue(false),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock('@bull-board/api');
|
vi.mock('@bull-board/api');
|
||||||
vi.mock('@bull-board/api/bullMQAdapter');
|
vi.mock('@bull-board/api/bullMQAdapter');
|
||||||
vi.mock('node:fs/promises');
|
vi.mock('node:fs/promises');
|
||||||
@@ -49,10 +66,13 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../services/logger.server', async () => ({
|
vi.mock('../services/logger.server', async () => {
|
||||||
// Use async import to avoid hoisting issues with mockLogger
|
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
return {
|
||||||
}));
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Import the router AFTER all mocks are defined.
|
// Import the router AFTER all mocks are defined.
|
||||||
import adminRouter from './admin.routes';
|
import adminRouter from './admin.routes';
|
||||||
@@ -62,7 +82,8 @@ import { adminRepo, userRepo } from '../services/db/index.db';
|
|||||||
import { userService } from '../services/userService';
|
import { userService } from '../services/userService';
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
|
||||||
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
|||||||
@@ -61,18 +61,43 @@ vi.mock('../services/queueService.server', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import the router AFTER all mocks are defined.
|
// Mock the monitoring service
|
||||||
import aiRouter from './ai.routes';
|
const { mockedMonitoringService } = vi.hoisted(() => ({
|
||||||
import { flyerQueue } from '../services/queueService.server';
|
mockedMonitoringService: {
|
||||||
|
getFlyerJobStatus: vi.fn(),
|
||||||
// Mock the logger to keep test output clean
|
},
|
||||||
vi.mock('../services/logger.server', async () => ({
|
}));
|
||||||
// Use async import to avoid hoisting issues with mockLogger
|
vi.mock('../services/monitoringService.server', () => ({
|
||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
monitoringService: mockedMonitoringService,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock env config to prevent parsing errors
|
||||||
|
vi.mock('../config/env', () => ({
|
||||||
|
config: {
|
||||||
|
database: { host: 'localhost', port: 5432, user: 'test', password: 'test', name: 'test' },
|
||||||
|
redis: { url: 'redis://localhost:6379' },
|
||||||
|
auth: { jwtSecret: 'test-secret' },
|
||||||
|
server: { port: 3000, host: 'localhost' },
|
||||||
|
ai: { enabled: true },
|
||||||
|
},
|
||||||
|
isAiConfigured: vi.fn().mockReturnValue(true),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import the router AFTER all mocks are defined.
|
||||||
|
import aiRouter from './ai.routes';
|
||||||
|
|
||||||
|
// Mock the logger to keep test output clean
|
||||||
|
vi.mock('../services/logger.server', async () => {
|
||||||
|
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
|
return {
|
||||||
|
logger: mockLogger,
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the passport module to control authentication for different tests.
|
// Mock the passport module to control authentication for different tests.
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
// Mock passport.authenticate to simply call next(), allowing the request to proceed.
|
// Mock passport.authenticate to simply call next(), allowing the request to proceed.
|
||||||
// The actual user object will be injected by the mockAuth middleware or test setup.
|
// The actual user object will be injected by the mockAuth middleware or test setup.
|
||||||
@@ -84,13 +109,19 @@ vi.mock('./passport.routes', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('AI Routes (/api/ai)', () => {
|
describe('AI Routes (/api/ai)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Reset logger implementation to no-op to prevent "Logging failed" leaks from previous tests
|
// Reset logger implementation to no-op to prevent "Logging failed" leaks from previous tests
|
||||||
vi.mocked(mockLogger.info).mockImplementation(() => {});
|
vi.mocked(mockLogger.info).mockImplementation(() => {});
|
||||||
vi.mocked(mockLogger.error).mockImplementation(() => {});
|
vi.mocked(mockLogger.error).mockImplementation(() => {});
|
||||||
vi.mocked(mockLogger.warn).mockImplementation(() => {});
|
vi.mocked(mockLogger.warn).mockImplementation(() => {});
|
||||||
vi.mocked(mockLogger.debug).mockImplementation(() => {}); // Ensure debug is also mocked
|
vi.mocked(mockLogger.debug).mockImplementation(() => {}); // Ensure debug is also mocked
|
||||||
|
|
||||||
|
// Default mock for monitoring service - returns NotFoundError for unknown jobs
|
||||||
|
const { NotFoundError } = await import('../services/db/errors.db');
|
||||||
|
vi.mocked(mockedMonitoringService.getFlyerJobStatus).mockRejectedValue(
|
||||||
|
new NotFoundError('Job not found.'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
|
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
|
||||||
|
|
||||||
@@ -301,8 +332,11 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
|
|
||||||
describe('GET /jobs/:jobId/status', () => {
|
describe('GET /jobs/:jobId/status', () => {
|
||||||
it('should return 404 if job is not found', async () => {
|
it('should return 404 if job is not found', async () => {
|
||||||
// Mock the queue to return null for the job
|
// Mock the monitoring service to throw NotFoundError
|
||||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
|
const { NotFoundError } = await import('../services/db/errors.db');
|
||||||
|
vi.mocked(mockedMonitoringService.getFlyerJobStatus).mockRejectedValue(
|
||||||
|
new NotFoundError('Job not found.'),
|
||||||
|
);
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/ai/jobs/non-existent-job/status');
|
const response = await supertest(app).get('/api/ai/jobs/non-existent-job/status');
|
||||||
|
|
||||||
@@ -311,13 +345,13 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return job status if job is found', async () => {
|
it('should return job status if job is found', async () => {
|
||||||
const mockJob = {
|
const mockJobStatus = {
|
||||||
id: 'job-123',
|
id: 'job-123',
|
||||||
getState: async () => 'completed',
|
state: 'completed',
|
||||||
progress: 100,
|
progress: 100,
|
||||||
returnvalue: { flyerId: 1 },
|
result: { flyerId: 1 },
|
||||||
};
|
};
|
||||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as unknown as Job);
|
vi.mocked(mockedMonitoringService.getFlyerJobStatus).mockResolvedValue(mockJobStatus);
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const passportMocks = vi.hoisted(() => {
|
|||||||
// --- 2. Module Mocks ---
|
// --- 2. Module Mocks ---
|
||||||
|
|
||||||
// Mock the local passport.routes module to control its behavior.
|
// Mock the local passport.routes module to control its behavior.
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn().mockImplementation(passportMocks.authenticateMock),
|
authenticate: vi.fn().mockImplementation(passportMocks.authenticateMock),
|
||||||
use: vi.fn(),
|
use: vi.fn(),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const mockUser = createMockUserProfile({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Standardized mock for passport.routes
|
// Standardized mock for passport.routes
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
req.user = mockUser;
|
req.user = mockUser;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock('../services/logger.server', async () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
// If req.user is not set by the test setup, simulate unauthenticated access.
|
// If req.user is not set by the test setup, simulate unauthenticated access.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const mockedAuthMiddleware = vi.hoisted(() =>
|
|||||||
);
|
);
|
||||||
const mockedIsAdmin = vi.hoisted(() => vi.fn());
|
const mockedIsAdmin = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
// The authenticate method will now call our hoisted mock middleware.
|
// The authenticate method will now call our hoisted mock middleware.
|
||||||
authenticate: vi.fn(() => mockedAuthMiddleware),
|
authenticate: vi.fn(() => mockedAuthMiddleware),
|
||||||
|
|||||||
@@ -220,7 +220,8 @@ describe('Inventory Routes (/api/inventory)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
expect(response.body.error.details[0].message).toMatch(/Item name/i);
|
// Zod returns a type error message when a required field is undefined
|
||||||
|
expect(response.body.error.details[0].message).toMatch(/expected string|required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 for invalid source', async () => {
|
it('should return 400 for invalid source', async () => {
|
||||||
|
|||||||
@@ -313,6 +313,322 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPIRING ITEMS ENDPOINTS
|
||||||
|
// NOTE: These routes MUST be defined BEFORE /:inventoryId to avoid path conflicts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /inventory/expiring/summary:
|
||||||
|
* get:
|
||||||
|
* tags: [Inventory]
|
||||||
|
* summary: Get expiring items summary
|
||||||
|
* description: Get items grouped by expiry urgency (today, this week, this month, expired).
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Expiring items grouped by urgency
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* expiring_today:
|
||||||
|
* type: array
|
||||||
|
* expiring_this_week:
|
||||||
|
* type: array
|
||||||
|
* expiring_this_month:
|
||||||
|
* type: array
|
||||||
|
* already_expired:
|
||||||
|
* type: array
|
||||||
|
* counts:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* today:
|
||||||
|
* type: integer
|
||||||
|
* this_week:
|
||||||
|
* type: integer
|
||||||
|
* this_month:
|
||||||
|
* type: integer
|
||||||
|
* expired:
|
||||||
|
* type: integer
|
||||||
|
* total:
|
||||||
|
* type: integer
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get('/expiring/summary', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const userProfile = req.user as UserProfile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await expiryService.getExpiringItemsGrouped(userProfile.user.user_id, req.log);
|
||||||
|
sendSuccess(res, result);
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error(
|
||||||
|
{ error, userId: userProfile.user.user_id },
|
||||||
|
'Error fetching expiring items summary',
|
||||||
|
);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /inventory/expiring:
|
||||||
|
* get:
|
||||||
|
* tags: [Inventory]
|
||||||
|
* summary: Get expiring items
|
||||||
|
* description: Get items expiring within a specified number of days.
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: days
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 90
|
||||||
|
* default: 7
|
||||||
|
* description: Number of days to look ahead
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Expiring items retrieved
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/expiring',
|
||||||
|
validateRequest(daysAheadQuerySchema),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const userProfile = req.user as UserProfile;
|
||||||
|
type ExpiringItemsRequest = z.infer<typeof daysAheadQuerySchema>;
|
||||||
|
const { query } = req as unknown as ExpiringItemsRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await expiryService.getExpiringItems(
|
||||||
|
userProfile.user.user_id,
|
||||||
|
query.days,
|
||||||
|
req.log,
|
||||||
|
);
|
||||||
|
sendSuccess(res, { items, total: items.length });
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching expiring items');
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /inventory/expired:
|
||||||
|
* get:
|
||||||
|
* tags: [Inventory]
|
||||||
|
* summary: Get expired items
|
||||||
|
* description: Get all items that have already expired.
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Expired items retrieved
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get('/expired', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const userProfile = req.user as UserProfile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await expiryService.getExpiredItems(userProfile.user.user_id, req.log);
|
||||||
|
sendSuccess(res, { items, total: items.length });
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching expired items');
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ALERT SETTINGS ENDPOINTS
|
||||||
|
// NOTE: These routes MUST be defined BEFORE /:inventoryId to avoid path conflicts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /inventory/alerts:
|
||||||
|
* get:
|
||||||
|
* tags: [Inventory]
|
||||||
|
* summary: Get alert settings
|
||||||
|
* description: Get the user's expiry alert settings.
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Alert settings retrieved
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get('/alerts', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const userProfile = req.user as UserProfile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await expiryService.getAlertSettings(userProfile.user.user_id, req.log);
|
||||||
|
sendSuccess(res, settings);
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching alert settings');
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /inventory/alerts/{alertMethod}:
|
||||||
|
* put:
|
||||||
|
* tags: [Inventory]
|
||||||
|
* summary: Update alert settings
|
||||||
|
* description: Update alert settings for a specific notification method.
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: alertMethod
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [email, push, in_app]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* days_before_expiry:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 30
|
||||||
|
* is_enabled:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Alert settings updated
|
||||||
|
* 400:
|
||||||
|
* description: Validation error
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/alerts/:alertMethod',
|
||||||
|
validateRequest(updateAlertSettingsSchema),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const userProfile = req.user as UserProfile;
|
||||||
|
type UpdateAlertRequest = z.infer<typeof updateAlertSettingsSchema>;
|
||||||
|
const { params, body } = req as unknown as UpdateAlertRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await expiryService.updateAlertSettings(
|
||||||
|
userProfile.user.user_id,
|
||||||
|
params.alertMethod,
|
||||||
|
body,
|
||||||
|
req.log,
|
||||||
|
);
|
||||||
|
sendSuccess(res, settings);
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error(
|
||||||
|
{ error, userId: userProfile.user.user_id, alertMethod: params.alertMethod },
|
||||||
|
'Error updating alert settings',
|
||||||
|
);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RECIPE SUGGESTIONS ENDPOINT
|
||||||
|
// NOTE: This route MUST be defined BEFORE /:inventoryId to avoid path conflicts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /inventory/recipes/suggestions:
|
||||||
|
* get:
|
||||||
|
* tags: [Inventory]
|
||||||
|
* summary: Get recipe suggestions for expiring items
|
||||||
|
* description: Get recipes that use items expiring soon to reduce food waste.
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: days
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 90
|
||||||
|
* default: 7
|
||||||
|
* description: Consider items expiring within this many days
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 50
|
||||||
|
* default: 10
|
||||||
|
* - in: query
|
||||||
|
* name: offset
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* default: 0
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Recipe suggestions retrieved
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/recipes/suggestions',
|
||||||
|
validateRequest(
|
||||||
|
z.object({
|
||||||
|
query: z.object({
|
||||||
|
days: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default('7')
|
||||||
|
.transform((val) => parseInt(val, 10))
|
||||||
|
.pipe(z.number().int().min(1).max(90)),
|
||||||
|
limit: optionalNumeric({ default: 10, min: 1, max: 50, integer: true }),
|
||||||
|
offset: optionalNumeric({ default: 0, min: 0, integer: true }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const userProfile = req.user as UserProfile;
|
||||||
|
const { query } = req as unknown as {
|
||||||
|
query: { days: number; limit?: number; offset?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await expiryService.getRecipeSuggestionsForExpiringItems(
|
||||||
|
userProfile.user.user_id,
|
||||||
|
query.days,
|
||||||
|
req.log,
|
||||||
|
{ limit: query.limit, offset: query.offset },
|
||||||
|
);
|
||||||
|
sendSuccess(res, result);
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error(
|
||||||
|
{ error, userId: userProfile.user.user_id },
|
||||||
|
'Error fetching recipe suggestions',
|
||||||
|
);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INVENTORY ITEM BY ID ENDPOINTS
|
||||||
|
// NOTE: These routes with /:inventoryId MUST come AFTER specific path routes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /inventory/{inventoryId}:
|
* /inventory/{inventoryId}:
|
||||||
@@ -528,312 +844,4 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EXPIRING ITEMS ENDPOINTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /inventory/expiring/summary:
|
|
||||||
* get:
|
|
||||||
* tags: [Inventory]
|
|
||||||
* summary: Get expiring items summary
|
|
||||||
* description: Get items grouped by expiry urgency (today, this week, this month, expired).
|
|
||||||
* security:
|
|
||||||
* - bearerAuth: []
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Expiring items grouped by urgency
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* expiring_today:
|
|
||||||
* type: array
|
|
||||||
* expiring_this_week:
|
|
||||||
* type: array
|
|
||||||
* expiring_this_month:
|
|
||||||
* type: array
|
|
||||||
* already_expired:
|
|
||||||
* type: array
|
|
||||||
* counts:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* today:
|
|
||||||
* type: integer
|
|
||||||
* this_week:
|
|
||||||
* type: integer
|
|
||||||
* this_month:
|
|
||||||
* type: integer
|
|
||||||
* expired:
|
|
||||||
* type: integer
|
|
||||||
* total:
|
|
||||||
* type: integer
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized
|
|
||||||
*/
|
|
||||||
router.get('/expiring/summary', async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const userProfile = req.user as UserProfile;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await expiryService.getExpiringItemsGrouped(userProfile.user.user_id, req.log);
|
|
||||||
sendSuccess(res, result);
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error(
|
|
||||||
{ error, userId: userProfile.user.user_id },
|
|
||||||
'Error fetching expiring items summary',
|
|
||||||
);
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /inventory/expiring:
|
|
||||||
* get:
|
|
||||||
* tags: [Inventory]
|
|
||||||
* summary: Get expiring items
|
|
||||||
* description: Get items expiring within a specified number of days.
|
|
||||||
* security:
|
|
||||||
* - bearerAuth: []
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: days
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* minimum: 1
|
|
||||||
* maximum: 90
|
|
||||||
* default: 7
|
|
||||||
* description: Number of days to look ahead
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Expiring items retrieved
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
'/expiring',
|
|
||||||
validateRequest(daysAheadQuerySchema),
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const userProfile = req.user as UserProfile;
|
|
||||||
type ExpiringItemsRequest = z.infer<typeof daysAheadQuerySchema>;
|
|
||||||
const { query } = req as unknown as ExpiringItemsRequest;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const items = await expiryService.getExpiringItems(
|
|
||||||
userProfile.user.user_id,
|
|
||||||
query.days,
|
|
||||||
req.log,
|
|
||||||
);
|
|
||||||
sendSuccess(res, { items, total: items.length });
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching expiring items');
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /inventory/expired:
|
|
||||||
* get:
|
|
||||||
* tags: [Inventory]
|
|
||||||
* summary: Get expired items
|
|
||||||
* description: Get all items that have already expired.
|
|
||||||
* security:
|
|
||||||
* - bearerAuth: []
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Expired items retrieved
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized
|
|
||||||
*/
|
|
||||||
router.get('/expired', async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const userProfile = req.user as UserProfile;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const items = await expiryService.getExpiredItems(userProfile.user.user_id, req.log);
|
|
||||||
sendSuccess(res, { items, total: items.length });
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching expired items');
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ALERT SETTINGS ENDPOINTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /inventory/alerts:
|
|
||||||
* get:
|
|
||||||
* tags: [Inventory]
|
|
||||||
* summary: Get alert settings
|
|
||||||
* description: Get the user's expiry alert settings.
|
|
||||||
* security:
|
|
||||||
* - bearerAuth: []
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Alert settings retrieved
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized
|
|
||||||
*/
|
|
||||||
router.get('/alerts', async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const userProfile = req.user as UserProfile;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = await expiryService.getAlertSettings(userProfile.user.user_id, req.log);
|
|
||||||
sendSuccess(res, settings);
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching alert settings');
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /inventory/alerts/{alertMethod}:
|
|
||||||
* put:
|
|
||||||
* tags: [Inventory]
|
|
||||||
* summary: Update alert settings
|
|
||||||
* description: Update alert settings for a specific notification method.
|
|
||||||
* security:
|
|
||||||
* - bearerAuth: []
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: alertMethod
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* enum: [email, push, in_app]
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* days_before_expiry:
|
|
||||||
* type: integer
|
|
||||||
* minimum: 1
|
|
||||||
* maximum: 30
|
|
||||||
* is_enabled:
|
|
||||||
* type: boolean
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Alert settings updated
|
|
||||||
* 400:
|
|
||||||
* description: Validation error
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized
|
|
||||||
*/
|
|
||||||
router.put(
|
|
||||||
'/alerts/:alertMethod',
|
|
||||||
validateRequest(updateAlertSettingsSchema),
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const userProfile = req.user as UserProfile;
|
|
||||||
type UpdateAlertRequest = z.infer<typeof updateAlertSettingsSchema>;
|
|
||||||
const { params, body } = req as unknown as UpdateAlertRequest;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = await expiryService.updateAlertSettings(
|
|
||||||
userProfile.user.user_id,
|
|
||||||
params.alertMethod,
|
|
||||||
body,
|
|
||||||
req.log,
|
|
||||||
);
|
|
||||||
sendSuccess(res, settings);
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error(
|
|
||||||
{ error, userId: userProfile.user.user_id, alertMethod: params.alertMethod },
|
|
||||||
'Error updating alert settings',
|
|
||||||
);
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// RECIPE SUGGESTIONS ENDPOINT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /inventory/recipes/suggestions:
|
|
||||||
* get:
|
|
||||||
* tags: [Inventory]
|
|
||||||
* summary: Get recipe suggestions for expiring items
|
|
||||||
* description: Get recipes that use items expiring soon to reduce food waste.
|
|
||||||
* security:
|
|
||||||
* - bearerAuth: []
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: days
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* minimum: 1
|
|
||||||
* maximum: 90
|
|
||||||
* default: 7
|
|
||||||
* description: Consider items expiring within this many days
|
|
||||||
* - in: query
|
|
||||||
* name: limit
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* minimum: 1
|
|
||||||
* maximum: 50
|
|
||||||
* default: 10
|
|
||||||
* - in: query
|
|
||||||
* name: offset
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* minimum: 0
|
|
||||||
* default: 0
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Recipe suggestions retrieved
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
'/recipes/suggestions',
|
|
||||||
validateRequest(
|
|
||||||
z.object({
|
|
||||||
query: z.object({
|
|
||||||
days: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default('7')
|
|
||||||
.transform((val) => parseInt(val, 10))
|
|
||||||
.pipe(z.number().int().min(1).max(90)),
|
|
||||||
limit: optionalNumeric({ default: 10, min: 1, max: 50, integer: true }),
|
|
||||||
offset: optionalNumeric({ default: 0, min: 0, integer: true }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const userProfile = req.user as UserProfile;
|
|
||||||
const { query } = req as unknown as {
|
|
||||||
query: { days: number; limit?: number; offset?: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await expiryService.getRecipeSuggestionsForExpiringItems(
|
|
||||||
userProfile.user.user_id,
|
|
||||||
query.days,
|
|
||||||
req.log,
|
|
||||||
{ limit: query.limit, offset: query.offset },
|
|
||||||
);
|
|
||||||
sendSuccess(res, result);
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error(
|
|
||||||
{ error, userId: userProfile.user.user_id },
|
|
||||||
'Error fetching recipe suggestions',
|
|
||||||
);
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ vi.mock('../services/logger.server', async () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the passport middleware
|
// Mock the passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
// If req.user is not set by the test setup, simulate unauthenticated access.
|
// If req.user is not set by the test setup, simulate unauthenticated access.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ vi.mock('../services/logger.server', async () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock Passport middleware
|
// Mock Passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
// If we are testing the unauthenticated state (no user injected), simulate 401.
|
// If we are testing the unauthenticated state (no user injected), simulate 401.
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { createTestApp } from '../tests/utils/createTestApp';
|
|||||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||||
import receiptRouter from './receipt.routes';
|
import receiptRouter from './receipt.routes';
|
||||||
import type { ReceiptStatus, ReceiptItemStatus } from '../types/expiry';
|
import type { ReceiptStatus, ReceiptItemStatus } from '../types/expiry';
|
||||||
|
import { NotFoundError } from '../services/db/errors.db';
|
||||||
|
|
||||||
|
// Test state - must be declared before vi.mock calls that reference them
|
||||||
|
let mockUser: ReturnType<typeof createMockUserProfile> | null = null;
|
||||||
|
let mockFile: Express.Multer.File | null = null;
|
||||||
|
|
||||||
// Mock passport
|
// Mock passport
|
||||||
vi.mock('../config/passport', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
@@ -17,6 +22,7 @@ vi.mock('../config/passport', () => ({
|
|||||||
res.status(401).json({ success: false, error: { message: 'Unauthorized' } });
|
res.status(401).json({ success: false, error: { message: 'Unauthorized' } });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
initialize: () => (req: any, res: any, next: any) => next(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -45,23 +51,36 @@ vi.mock('../services/queues.server', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock multer middleware
|
// Mock multer middleware
|
||||||
vi.mock('../middleware/multer.middleware', () => ({
|
vi.mock('../middleware/multer.middleware', () => {
|
||||||
createUploadMiddleware: vi.fn(() => ({
|
return {
|
||||||
single: vi.fn(() => (req: any, _res: any, next: any) => {
|
createUploadMiddleware: vi.fn(() => ({
|
||||||
// Simulate file upload
|
single: vi.fn(() => (req: any, _res: any, next: any) => {
|
||||||
if (mockFile) {
|
// Simulate file upload by setting req.file
|
||||||
req.file = mockFile;
|
if (mockFile) {
|
||||||
|
req.file = mockFile;
|
||||||
|
}
|
||||||
|
// Multer also parses the body fields from multipart form data.
|
||||||
|
// Since we're mocking multer, we need to ensure req.body is an object.
|
||||||
|
// Supertest with .field() sends data as multipart which express.json() doesn't parse.
|
||||||
|
// The actual field data won't be in req.body from supertest when multer is mocked,
|
||||||
|
// so we leave req.body as-is (express.json() will have parsed JSON requests,
|
||||||
|
// and for multipart we need to ensure body is at least an empty object).
|
||||||
|
if (req.body === undefined) {
|
||||||
|
req.body = {};
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
handleMulterError: vi.fn((err: any, _req: any, res: any, next: any) => {
|
||||||
|
// Only handle multer-specific errors, pass others to the error handler
|
||||||
|
if (err && err.name === 'MulterError') {
|
||||||
|
return res.status(400).json({ success: false, error: { message: err.message } });
|
||||||
}
|
}
|
||||||
next();
|
// Pass non-multer errors to the next error handler
|
||||||
|
next(err);
|
||||||
}),
|
}),
|
||||||
})),
|
};
|
||||||
handleMulterError: vi.fn((err: any, _req: any, res: any, next: any) => {
|
});
|
||||||
if (err) {
|
|
||||||
return res.status(400).json({ success: false, error: { message: err.message } });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock file upload middleware
|
// Mock file upload middleware
|
||||||
vi.mock('../middleware/fileUpload.middleware', () => ({
|
vi.mock('../middleware/fileUpload.middleware', () => ({
|
||||||
@@ -80,10 +99,6 @@ import * as receiptService from '../services/receiptService.server';
|
|||||||
import * as expiryService from '../services/expiryService.server';
|
import * as expiryService from '../services/expiryService.server';
|
||||||
import { receiptQueue } from '../services/queues.server';
|
import { receiptQueue } from '../services/queues.server';
|
||||||
|
|
||||||
// Test state
|
|
||||||
let mockUser: ReturnType<typeof createMockUserProfile> | null = null;
|
|
||||||
let mockFile: Express.Multer.File | null = null;
|
|
||||||
|
|
||||||
// Helper to create mock receipt (ReceiptScan type)
|
// Helper to create mock receipt (ReceiptScan type)
|
||||||
function createMockReceipt(overrides: { status?: ReceiptStatus; [key: string]: unknown } = {}) {
|
function createMockReceipt(overrides: { status?: ReceiptStatus; [key: string]: unknown } = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -294,10 +309,10 @@ describe('Receipt Routes', () => {
|
|||||||
vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt);
|
vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt);
|
||||||
vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-123' } as any);
|
vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-123' } as any);
|
||||||
|
|
||||||
|
// Send JSON body instead of form fields since multer is mocked and doesn't parse form data
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/receipts')
|
.post('/receipts')
|
||||||
.field('store_id', '1')
|
.send({ store_id: '1', transaction_date: '2024-01-15' });
|
||||||
.field('transaction_date', '2024-01-15');
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(response.body.success).toBe(true);
|
expect(response.body.success).toBe(true);
|
||||||
@@ -384,9 +399,9 @@ describe('Receipt Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent receipt', async () => {
|
it('should return 404 for non-existent receipt', async () => {
|
||||||
const notFoundError = new Error('Receipt not found');
|
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
|
||||||
(notFoundError as any).statusCode = 404;
|
new NotFoundError('Receipt not found'),
|
||||||
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError);
|
);
|
||||||
|
|
||||||
const response = await request(app).get('/receipts/999');
|
const response = await request(app).get('/receipts/999');
|
||||||
|
|
||||||
@@ -415,9 +430,9 @@ describe('Receipt Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent receipt', async () => {
|
it('should return 404 for non-existent receipt', async () => {
|
||||||
const notFoundError = new Error('Receipt not found');
|
vi.mocked(receiptService.deleteReceipt).mockRejectedValueOnce(
|
||||||
(notFoundError as any).statusCode = 404;
|
new NotFoundError('Receipt not found'),
|
||||||
vi.mocked(receiptService.deleteReceipt).mockRejectedValueOnce(notFoundError);
|
);
|
||||||
|
|
||||||
const response = await request(app).delete('/receipts/999');
|
const response = await request(app).delete('/receipts/999');
|
||||||
|
|
||||||
@@ -450,9 +465,9 @@ describe('Receipt Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent receipt', async () => {
|
it('should return 404 for non-existent receipt', async () => {
|
||||||
const notFoundError = new Error('Receipt not found');
|
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
|
||||||
(notFoundError as any).statusCode = 404;
|
new NotFoundError('Receipt not found'),
|
||||||
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError);
|
);
|
||||||
|
|
||||||
const response = await request(app).post('/receipts/999/reprocess');
|
const response = await request(app).post('/receipts/999/reprocess');
|
||||||
|
|
||||||
@@ -480,9 +495,9 @@ describe('Receipt Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if receipt not found', async () => {
|
it('should return 404 if receipt not found', async () => {
|
||||||
const notFoundError = new Error('Receipt not found');
|
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
|
||||||
(notFoundError as any).statusCode = 404;
|
new NotFoundError('Receipt not found'),
|
||||||
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError);
|
);
|
||||||
|
|
||||||
const response = await request(app).get('/receipts/999/items');
|
const response = await request(app).get('/receipts/999/items');
|
||||||
|
|
||||||
@@ -648,11 +663,14 @@ describe('Receipt Routes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject empty items array', async () => {
|
it('should accept empty items array', async () => {
|
||||||
|
// Empty array is technically valid, service decides what to do
|
||||||
|
vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const response = await request(app).post('/receipts/1/confirm').send({ items: [] });
|
const response = await request(app).post('/receipts/1/confirm').send({ items: [] });
|
||||||
|
|
||||||
// Empty array is technically valid, service decides what to do
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.data.count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject missing items field', async () => {
|
it('should reject missing items field', async () => {
|
||||||
@@ -740,9 +758,9 @@ describe('Receipt Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent receipt', async () => {
|
it('should return 404 for non-existent receipt', async () => {
|
||||||
const notFoundError = new Error('Receipt not found');
|
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
|
||||||
(notFoundError as any).statusCode = 404;
|
new NotFoundError('Receipt not found'),
|
||||||
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError);
|
);
|
||||||
|
|
||||||
const response = await request(app).get('/receipts/999/logs');
|
const response = await request(app).get('/receipts/999/logs');
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ vi.mock('../services/aiService.server', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock Passport
|
// Mock Passport
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
|
|||||||
@@ -36,10 +36,14 @@ const _mockAdminUser = createMockUserProfile({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Standardized mock for passport
|
// Standardized mock for passport
|
||||||
|
// Note: createTestApp sets req.user before the router runs, so we preserve it here
|
||||||
vi.mock('../config/passport', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
req.user = mockUser;
|
// Preserve the user set by createTestApp if already present
|
||||||
|
if (!req.user) {
|
||||||
|
req.user = mockUser;
|
||||||
|
}
|
||||||
next();
|
next();
|
||||||
}),
|
}),
|
||||||
initialize: () => (req: Request, res: Response, next: NextFunction) => next(),
|
initialize: () => (req: Request, res: Response, next: NextFunction) => next(),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import userRouter from './user.routes';
|
|||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
|
|
||||||
// Mock Passport middleware
|
// Mock Passport middleware
|
||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(
|
authenticate: vi.fn(
|
||||||
() => (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
() => (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ import { ValidationError } from './db/errors.db';
|
|||||||
import { AiFlyerDataSchema } from '../types/ai';
|
import { AiFlyerDataSchema } from '../types/ai';
|
||||||
|
|
||||||
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
|
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
|
||||||
vi.mock('./logger.server', () => ({
|
vi.mock('./logger.server', async () => {
|
||||||
logger: createMockLogger(),
|
const { createMockLogger } = await import('../tests/utils/mockLogger');
|
||||||
}));
|
return {
|
||||||
|
logger: createMockLogger(),
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Import the mocked logger instance to pass to the service constructor.
|
// Import the mocked logger instance to pass to the service constructor.
|
||||||
import { logger as mockLoggerInstance } from './logger.server';
|
import { logger as mockLoggerInstance } from './logger.server';
|
||||||
@@ -1096,6 +1100,11 @@ describe('AI Service (Server)', () => {
|
|||||||
submitterIp: '127.0.0.1',
|
submitterIp: '127.0.0.1',
|
||||||
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
|
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
|
||||||
baseUrl: 'https://example.com',
|
baseUrl: 'https://example.com',
|
||||||
|
meta: {
|
||||||
|
requestId: undefined,
|
||||||
|
userId: 'user123',
|
||||||
|
origin: 'api',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(result.id).toBe('job123');
|
expect(result.id).toBe('job123');
|
||||||
});
|
});
|
||||||
@@ -1118,6 +1127,11 @@ describe('AI Service (Server)', () => {
|
|||||||
userId: undefined,
|
userId: undefined,
|
||||||
userProfileAddress: undefined,
|
userProfileAddress: undefined,
|
||||||
baseUrl: 'https://example.com',
|
baseUrl: 'https://example.com',
|
||||||
|
meta: {
|
||||||
|
requestId: undefined,
|
||||||
|
userId: undefined,
|
||||||
|
origin: 'api',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ describe('API Client', () => {
|
|||||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
|
headers: new Headers(),
|
||||||
clone: () => ({ text: () => Promise.resolve('Internal Server Error') }),
|
clone: () => ({ text: () => Promise.resolve('Internal Server Error') }),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { Job } from 'bullmq';
|
|||||||
import type { BarcodeDetectionJobData } from '../types/job-data';
|
import type { BarcodeDetectionJobData } from '../types/job-data';
|
||||||
import { createMockLogger } from '../tests/utils/mockLogger';
|
import { createMockLogger } from '../tests/utils/mockLogger';
|
||||||
|
|
||||||
|
// Unmock the barcodeService module so we can test the real implementation
|
||||||
|
// The global test setup mocks this to prevent zxing-wasm issues, but we need the real module here
|
||||||
|
vi.unmock('./barcodeService.server');
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('zxing-wasm/reader', () => ({
|
vi.mock('zxing-wasm/reader', () => ({
|
||||||
readBarcodesFromImageData: vi.fn(),
|
readBarcodesFromImageData: vi.fn(),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('ExpiryRepository', () => {
|
|||||||
|
|
||||||
describe('addInventoryItem', () => {
|
describe('addInventoryItem', () => {
|
||||||
it('should add inventory item with master item lookup', async () => {
|
it('should add inventory item with master item lookup', async () => {
|
||||||
// Master item lookup query
|
// Master item lookup query (only called when item_name is NOT provided)
|
||||||
mockQuery.mockResolvedValueOnce({
|
mockQuery.mockResolvedValueOnce({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
rows: [{ name: 'Milk' }],
|
rows: [{ name: 'Milk' }],
|
||||||
@@ -67,10 +67,13 @@ describe('ExpiryRepository', () => {
|
|||||||
rows: [pantryItemRow],
|
rows: [pantryItemRow],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When item_name is NOT provided but master_item_id IS provided,
|
||||||
|
// the function looks up the item name from master_grocery_items
|
||||||
const result = await repo.addInventoryItem(
|
const result = await repo.addInventoryItem(
|
||||||
'user-1',
|
'user-1',
|
||||||
{
|
{
|
||||||
item_name: 'Milk',
|
// item_name is required by type but will be overwritten by master item lookup
|
||||||
|
item_name: '',
|
||||||
master_item_id: 100,
|
master_item_id: 100,
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
unit: 'liters',
|
unit: 'liters',
|
||||||
@@ -836,10 +839,7 @@ describe('ExpiryRepository', () => {
|
|||||||
const result = await repo.getUsersWithExpiringItems(mockLogger);
|
const result = await repo.getUsersWithExpiringItems(mockLogger);
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(mockQuery).toHaveBeenCalledWith(
|
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('ea.is_enabled = true'));
|
||||||
expect.stringContaining('ea.is_enabled = true'),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,13 +19,19 @@ vi.mock('./gamification.db', () => ({
|
|||||||
GamificationRepository: class GamificationRepository {},
|
GamificationRepository: class GamificationRepository {},
|
||||||
}));
|
}));
|
||||||
vi.mock('./admin.db', () => ({ AdminRepository: class AdminRepository {} }));
|
vi.mock('./admin.db', () => ({ AdminRepository: class AdminRepository {} }));
|
||||||
|
vi.mock('./upc.db', () => ({ UpcRepository: class UpcRepository {} }));
|
||||||
|
vi.mock('./expiry.db', () => ({ ExpiryRepository: class ExpiryRepository {} }));
|
||||||
|
vi.mock('./receipt.db', () => ({ ReceiptRepository: class ReceiptRepository {} }));
|
||||||
|
|
||||||
// These modules export an already-instantiated object, so we mock the object.
|
// These modules export an already-instantiated object, so we mock the object.
|
||||||
vi.mock('./reaction.db', () => ({ reactionRepo: {} }));
|
vi.mock('./reaction.db', () => ({ reactionRepo: {} }));
|
||||||
vi.mock('./conversion.db', () => ({ conversionRepo: {} }));
|
vi.mock('./conversion.db', () => ({ conversionRepo: {} }));
|
||||||
|
|
||||||
// Mock the re-exported function.
|
// Mock the re-exported function and getPool.
|
||||||
vi.mock('./connection.db', () => ({ withTransaction: vi.fn() }));
|
vi.mock('./connection.db', () => ({
|
||||||
|
withTransaction: vi.fn(),
|
||||||
|
getPool: vi.fn(() => ({ query: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
// We must un-mock the file we are testing so we get the actual implementation.
|
// We must un-mock the file we are testing so we get the actual implementation.
|
||||||
vi.unmock('./index.db');
|
vi.unmock('./index.db');
|
||||||
@@ -44,6 +50,9 @@ import { NotificationRepository } from './notification.db';
|
|||||||
import { BudgetRepository } from './budget.db';
|
import { BudgetRepository } from './budget.db';
|
||||||
import { GamificationRepository } from './gamification.db';
|
import { GamificationRepository } from './gamification.db';
|
||||||
import { AdminRepository } from './admin.db';
|
import { AdminRepository } from './admin.db';
|
||||||
|
import { UpcRepository } from './upc.db';
|
||||||
|
import { ExpiryRepository } from './expiry.db';
|
||||||
|
import { ReceiptRepository } from './receipt.db';
|
||||||
|
|
||||||
describe('DB Index', () => {
|
describe('DB Index', () => {
|
||||||
it('should instantiate and export all repositories and functions', () => {
|
it('should instantiate and export all repositories and functions', () => {
|
||||||
@@ -57,8 +66,11 @@ describe('DB Index', () => {
|
|||||||
expect(db.budgetRepo).toBeInstanceOf(BudgetRepository);
|
expect(db.budgetRepo).toBeInstanceOf(BudgetRepository);
|
||||||
expect(db.gamificationRepo).toBeInstanceOf(GamificationRepository);
|
expect(db.gamificationRepo).toBeInstanceOf(GamificationRepository);
|
||||||
expect(db.adminRepo).toBeInstanceOf(AdminRepository);
|
expect(db.adminRepo).toBeInstanceOf(AdminRepository);
|
||||||
|
expect(db.upcRepo).toBeInstanceOf(UpcRepository);
|
||||||
|
expect(db.expiryRepo).toBeInstanceOf(ExpiryRepository);
|
||||||
|
expect(db.receiptRepo).toBeInstanceOf(ReceiptRepository);
|
||||||
expect(db.reactionRepo).toBeDefined();
|
expect(db.reactionRepo).toBeDefined();
|
||||||
expect(db.conversionRepo).toBeDefined();
|
expect(db.conversionRepo).toBeDefined();
|
||||||
expect(db.withTransaction).toBeDefined();
|
expect(db.withTransaction).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -960,14 +960,8 @@ describe('ReceiptRepository', () => {
|
|||||||
const result = await repo.getActiveStorePatterns(mockLogger);
|
const result = await repo.getActiveStorePatterns(mockLogger);
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(mockQuery).toHaveBeenCalledWith(
|
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('is_active = true'));
|
||||||
expect.stringContaining('is_active = true'),
|
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('ORDER BY priority DESC'));
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(mockQuery).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('ORDER BY priority DESC'),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ const mocks = vi.hoisted(() => ({
|
|||||||
readdir: vi.fn(),
|
readdir: vi.fn(),
|
||||||
execAsync: vi.fn(),
|
execAsync: vi.fn(),
|
||||||
mockAdminLogActivity: vi.fn(),
|
mockAdminLogActivity: vi.fn(),
|
||||||
|
// Shared mock logger for verifying calls
|
||||||
|
sharedMockLogger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
child: vi.fn().mockReturnThis(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 2. Mock modules using the hoisted variables
|
// 2. Mock modules using the hoisted variables
|
||||||
@@ -68,14 +76,10 @@ vi.mock('./db/admin.db', () => ({
|
|||||||
return { logActivity: mocks.mockAdminLogActivity };
|
return { logActivity: mocks.mockAdminLogActivity };
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
// Use the hoisted shared mock logger instance so tests can verify calls
|
||||||
vi.mock('./logger.server', () => ({
|
vi.mock('./logger.server', () => ({
|
||||||
logger: {
|
logger: mocks.sharedMockLogger,
|
||||||
info: vi.fn(),
|
createScopedLogger: vi.fn(() => mocks.sharedMockLogger),
|
||||||
error: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
child: vi.fn().mockReturnThis(),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
vi.mock('./flyerFileHandler.server');
|
vi.mock('./flyerFileHandler.server');
|
||||||
vi.mock('./flyerAiProcessor.server');
|
vi.mock('./flyerAiProcessor.server');
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ const mocks = vi.hoisted(() => {
|
|||||||
|
|
||||||
const createMockQueue = (name: string) => ({
|
const createMockQueue = (name: string) => ({
|
||||||
name,
|
name,
|
||||||
getJobCounts: vi.fn().mockResolvedValue({}),
|
getJobCounts: vi.fn().mockResolvedValue({
|
||||||
|
waiting: 0,
|
||||||
|
active: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
delayed: 0,
|
||||||
|
paused: 0,
|
||||||
|
}),
|
||||||
getJob: vi.fn(),
|
getJob: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,22 +30,25 @@ const mocks = vi.hoisted(() => {
|
|||||||
analyticsWorker: createMockWorker('analytics-reporting'),
|
analyticsWorker: createMockWorker('analytics-reporting'),
|
||||||
cleanupWorker: createMockWorker('file-cleanup'),
|
cleanupWorker: createMockWorker('file-cleanup'),
|
||||||
weeklyAnalyticsWorker: createMockWorker('weekly-analytics-reporting'),
|
weeklyAnalyticsWorker: createMockWorker('weekly-analytics-reporting'),
|
||||||
|
tokenCleanupWorker: createMockWorker('token-cleanup'),
|
||||||
|
|
||||||
flyerQueue: createMockQueue('flyer-processing'),
|
flyerQueue: createMockQueue('flyer-processing'),
|
||||||
emailQueue: createMockQueue('email-sending'),
|
emailQueue: createMockQueue('email-sending'),
|
||||||
analyticsQueue: createMockQueue('analytics-reporting'),
|
analyticsQueue: createMockQueue('analytics-reporting'),
|
||||||
cleanupQueue: createMockQueue('file-cleanup'),
|
cleanupQueue: createMockQueue('file-cleanup'),
|
||||||
weeklyAnalyticsQueue: createMockQueue('weekly-analytics-reporting'),
|
weeklyAnalyticsQueue: createMockQueue('weekly-analytics-reporting'),
|
||||||
|
tokenCleanupQueue: createMockQueue('token-cleanup'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Mock Modules ---
|
// --- Mock Modules ---
|
||||||
vi.mock('./queueService.server', () => ({
|
vi.mock('./queues.server', () => ({
|
||||||
flyerQueue: mocks.flyerQueue,
|
flyerQueue: mocks.flyerQueue,
|
||||||
emailQueue: mocks.emailQueue,
|
emailQueue: mocks.emailQueue,
|
||||||
analyticsQueue: mocks.analyticsQueue,
|
analyticsQueue: mocks.analyticsQueue,
|
||||||
cleanupQueue: mocks.cleanupQueue,
|
cleanupQueue: mocks.cleanupQueue,
|
||||||
weeklyAnalyticsQueue: mocks.weeklyAnalyticsQueue,
|
weeklyAnalyticsQueue: mocks.weeklyAnalyticsQueue,
|
||||||
|
tokenCleanupQueue: mocks.tokenCleanupQueue,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./workers.server', () => ({
|
vi.mock('./workers.server', () => ({
|
||||||
@@ -47,6 +57,8 @@ vi.mock('./workers.server', () => ({
|
|||||||
analyticsWorker: mocks.analyticsWorker,
|
analyticsWorker: mocks.analyticsWorker,
|
||||||
cleanupWorker: mocks.cleanupWorker,
|
cleanupWorker: mocks.cleanupWorker,
|
||||||
weeklyAnalyticsWorker: mocks.weeklyAnalyticsWorker,
|
weeklyAnalyticsWorker: mocks.weeklyAnalyticsWorker,
|
||||||
|
tokenCleanupWorker: mocks.tokenCleanupWorker,
|
||||||
|
flyerProcessingService: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./db/errors.db', () => ({
|
vi.mock('./db/errors.db', () => ({
|
||||||
@@ -96,6 +108,7 @@ describe('MonitoringService', () => {
|
|||||||
{ name: 'analytics-reporting', isRunning: true },
|
{ name: 'analytics-reporting', isRunning: true },
|
||||||
{ name: 'file-cleanup', isRunning: true },
|
{ name: 'file-cleanup', isRunning: true },
|
||||||
{ name: 'weekly-analytics-reporting', isRunning: true },
|
{ name: 'weekly-analytics-reporting', isRunning: true },
|
||||||
|
{ name: 'token-cleanup', isRunning: true },
|
||||||
]);
|
]);
|
||||||
expect(mocks.flyerWorker.isRunning).toHaveBeenCalledTimes(1);
|
expect(mocks.flyerWorker.isRunning).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.emailWorker.isRunning).toHaveBeenCalledTimes(1);
|
expect(mocks.emailWorker.isRunning).toHaveBeenCalledTimes(1);
|
||||||
@@ -104,9 +117,22 @@ describe('MonitoringService', () => {
|
|||||||
|
|
||||||
describe('getQueueStatuses', () => {
|
describe('getQueueStatuses', () => {
|
||||||
it('should return job counts for all queues', async () => {
|
it('should return job counts for all queues', async () => {
|
||||||
// Arrange
|
const defaultCounts = {
|
||||||
mocks.flyerQueue.getJobCounts.mockResolvedValue({ active: 1, failed: 2 });
|
waiting: 0,
|
||||||
mocks.emailQueue.getJobCounts.mockResolvedValue({ completed: 10, waiting: 5 });
|
active: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
delayed: 0,
|
||||||
|
paused: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Arrange - override specific queue counts
|
||||||
|
mocks.flyerQueue.getJobCounts.mockResolvedValue({ ...defaultCounts, active: 1, failed: 2 });
|
||||||
|
mocks.emailQueue.getJobCounts.mockResolvedValue({
|
||||||
|
...defaultCounts,
|
||||||
|
completed: 10,
|
||||||
|
waiting: 5,
|
||||||
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const statuses = await monitoringService.getQueueStatuses();
|
const statuses = await monitoringService.getQueueStatuses();
|
||||||
@@ -114,11 +140,12 @@ describe('MonitoringService', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(statuses).toEqual(
|
expect(statuses).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
{ name: 'flyer-processing', counts: { active: 1, failed: 2 } },
|
{ name: 'flyer-processing', counts: { ...defaultCounts, active: 1, failed: 2 } },
|
||||||
{ name: 'email-sending', counts: { completed: 10, waiting: 5 } },
|
{ name: 'email-sending', counts: { ...defaultCounts, completed: 10, waiting: 5 } },
|
||||||
{ name: 'analytics-reporting', counts: {} },
|
{ name: 'analytics-reporting', counts: defaultCounts },
|
||||||
{ name: 'file-cleanup', counts: {} },
|
{ name: 'file-cleanup', counts: defaultCounts },
|
||||||
{ name: 'weekly-analytics-reporting', counts: {} },
|
{ name: 'weekly-analytics-reporting', counts: defaultCounts },
|
||||||
|
{ name: 'token-cleanup', counts: defaultCounts },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(mocks.flyerQueue.getJobCounts).toHaveBeenCalledTimes(1);
|
expect(mocks.flyerQueue.getJobCounts).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -56,22 +56,58 @@ vi.mock('bullmq', () => ({
|
|||||||
UnrecoverableError: class UnrecoverableError extends Error {},
|
UnrecoverableError: class UnrecoverableError extends Error {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./logger.server', () => ({
|
vi.mock('./logger.server', () => {
|
||||||
logger: {
|
// Mock logger factory that returns a new mock logger instance
|
||||||
|
const createMockLogger = () => ({
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
warn: vi.fn(), // This was a duplicate, fixed.
|
warn: vi.fn(),
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
child: vi.fn().mockReturnThis(),
|
child: vi.fn().mockReturnThis(),
|
||||||
|
trace: vi.fn(),
|
||||||
|
fatal: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
child: vi.fn().mockReturnThis(),
|
||||||
|
},
|
||||||
|
// createScopedLogger is used by aiService.server and other services
|
||||||
|
createScopedLogger: vi.fn(() => createMockLogger()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the config/env module to prevent env parsing during tests
|
||||||
|
vi.mock('../config/env', () => ({
|
||||||
|
config: {
|
||||||
|
database: { host: 'localhost', port: 5432, user: 'test', password: 'test', name: 'test' },
|
||||||
|
redis: { url: 'redis://localhost:6379' },
|
||||||
|
auth: { jwtSecret: 'test-secret' },
|
||||||
|
server: { port: 3000, host: 'localhost' },
|
||||||
},
|
},
|
||||||
|
isAiConfigured: vi.fn().mockReturnValue(false),
|
||||||
|
parseConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock other dependencies that are not the focus of this test file.
|
// Mock other dependencies that are not the focus of this test file.
|
||||||
vi.mock('./aiService.server');
|
vi.mock('./aiService.server');
|
||||||
vi.mock('./emailService.server');
|
vi.mock('./emailService.server');
|
||||||
vi.mock('./db/index.db'); // This was a duplicate, fixed.
|
vi.mock('./db/index.db');
|
||||||
|
vi.mock('./db/connection.db');
|
||||||
vi.mock('./flyerProcessingService.server');
|
vi.mock('./flyerProcessingService.server');
|
||||||
vi.mock('./flyerDataTransformer');
|
vi.mock('./flyerDataTransformer');
|
||||||
|
vi.mock('./flyerAiProcessor.server');
|
||||||
|
vi.mock('./flyerPersistenceService.server');
|
||||||
|
vi.mock('./flyerFileHandler.server');
|
||||||
|
vi.mock('./analyticsService.server');
|
||||||
|
vi.mock('./userService');
|
||||||
|
vi.mock('./receiptService.server');
|
||||||
|
vi.mock('./expiryService.server');
|
||||||
|
vi.mock('./barcodeService.server');
|
||||||
|
|
||||||
describe('Worker Service Lifecycle', () => {
|
describe('Worker Service Lifecycle', () => {
|
||||||
let gracefulShutdown: (signal: string) => Promise<void>; // This was a duplicate, fixed.
|
let gracefulShutdown: (signal: string) => Promise<void>; // This was a duplicate, fixed.
|
||||||
@@ -229,9 +265,7 @@ describe('Worker Service Lifecycle', () => {
|
|||||||
expect(mockRedisConnection.quit).toHaveBeenCalledTimes(1);
|
expect(mockRedisConnection.quit).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Check for the correct success log message from workers.server.ts
|
// Check for the correct success log message from workers.server.ts
|
||||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
expect(mockLogger.info).toHaveBeenCalledWith('[Shutdown] All resources closed successfully.');
|
||||||
'[Shutdown] All resources closed successfully.',
|
|
||||||
);
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const mocks = vi.hoisted(() => {
|
|||||||
weeklyAnalyticsQueue: createMockQueue('weekly-analytics-reporting'),
|
weeklyAnalyticsQueue: createMockQueue('weekly-analytics-reporting'),
|
||||||
cleanupQueue: createMockQueue('file-cleanup'),
|
cleanupQueue: createMockQueue('file-cleanup'),
|
||||||
tokenCleanupQueue: createMockQueue('token-cleanup'),
|
tokenCleanupQueue: createMockQueue('token-cleanup'),
|
||||||
|
receiptQueue: createMockQueue('receipt-processing'),
|
||||||
|
expiryAlertQueue: createMockQueue('expiry-alerts'),
|
||||||
|
barcodeQueue: createMockQueue('barcode-detection'),
|
||||||
redisConnection: {
|
redisConnection: {
|
||||||
quit: vi.fn().mockResolvedValue('OK'),
|
quit: vi.fn().mockResolvedValue('OK'),
|
||||||
},
|
},
|
||||||
@@ -36,6 +39,9 @@ vi.mock('./queues.server', () => ({
|
|||||||
weeklyAnalyticsQueue: mocks.weeklyAnalyticsQueue,
|
weeklyAnalyticsQueue: mocks.weeklyAnalyticsQueue,
|
||||||
cleanupQueue: mocks.cleanupQueue,
|
cleanupQueue: mocks.cleanupQueue,
|
||||||
tokenCleanupQueue: mocks.tokenCleanupQueue,
|
tokenCleanupQueue: mocks.tokenCleanupQueue,
|
||||||
|
receiptQueue: mocks.receiptQueue,
|
||||||
|
expiryAlertQueue: mocks.expiryAlertQueue,
|
||||||
|
barcodeQueue: mocks.barcodeQueue,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./redis.server', () => ({
|
vi.mock('./redis.server', () => ({
|
||||||
@@ -76,6 +82,9 @@ describe('Queue Service (API Shutdown)', () => {
|
|||||||
expect(mocks.cleanupQueue.close).toHaveBeenCalledTimes(1);
|
expect(mocks.cleanupQueue.close).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.weeklyAnalyticsQueue.close).toHaveBeenCalledTimes(1);
|
expect(mocks.weeklyAnalyticsQueue.close).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.tokenCleanupQueue.close).toHaveBeenCalledTimes(1);
|
expect(mocks.tokenCleanupQueue.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.receiptQueue.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.expiryAlertQueue.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.barcodeQueue.close).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.redisConnection.quit).toHaveBeenCalledTimes(1);
|
expect(mocks.redisConnection.quit).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +107,9 @@ describe('Queue Service (API Shutdown)', () => {
|
|||||||
{ err: closeError, resource: 'emailQueue' },
|
{ err: closeError, resource: 'emailQueue' },
|
||||||
'[Shutdown] Error closing resource.',
|
'[Shutdown] Error closing resource.',
|
||||||
);
|
);
|
||||||
expect(mocks.logger.warn).toHaveBeenCalledWith('[Shutdown] Graceful shutdown completed with errors.');
|
expect(mocks.logger.warn).toHaveBeenCalledWith(
|
||||||
|
'[Shutdown] Graceful shutdown completed with errors.',
|
||||||
|
);
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +123,9 @@ describe('Queue Service (API Shutdown)', () => {
|
|||||||
{ err: redisError, resource: 'redisConnection' },
|
{ err: redisError, resource: 'redisConnection' },
|
||||||
'[Shutdown] Error closing resource.',
|
'[Shutdown] Error closing resource.',
|
||||||
);
|
);
|
||||||
expect(mocks.logger.warn).toHaveBeenCalledWith('[Shutdown] Graceful shutdown completed with errors.');
|
expect(mocks.logger.warn).toHaveBeenCalledWith(
|
||||||
|
'[Shutdown] Graceful shutdown completed with errors.',
|
||||||
|
);
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,8 +112,50 @@ describe('Queue Definitions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create exactly 6 queues', () => {
|
it('should create receiptQueue with the correct name and options', () => {
|
||||||
|
expect(mocks.MockQueue).toHaveBeenCalledWith('receipt-processing', {
|
||||||
|
connection: mocks.mockConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 10000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create expiryAlertQueue with the correct name and options', () => {
|
||||||
|
expect(mocks.MockQueue).toHaveBeenCalledWith('expiry-alerts', {
|
||||||
|
connection: mocks.mockConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 2,
|
||||||
|
backoff: { type: 'exponential', delay: 300000 },
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create barcodeQueue with the correct name and options', () => {
|
||||||
|
expect(mocks.MockQueue).toHaveBeenCalledWith('barcode-detection', {
|
||||||
|
connection: mocks.mockConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 2,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 5000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 50,
|
||||||
|
removeOnFail: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create exactly 9 queues', () => {
|
||||||
// This is a good sanity check to ensure no new queues were added without tests.
|
// This is a good sanity check to ensure no new queues were added without tests.
|
||||||
expect(mocks.MockQueue).toHaveBeenCalledTimes(6);
|
expect(mocks.MockQueue).toHaveBeenCalledTimes(9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
11
src/tests/mocks/zxing-wasm-reader.mock.ts
Normal file
11
src/tests/mocks/zxing-wasm-reader.mock.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// src/tests/mocks/zxing-wasm-reader.mock.ts
|
||||||
|
/**
|
||||||
|
* Mock for zxing-wasm/reader module.
|
||||||
|
* The actual module uses WebAssembly which doesn't work in jsdom test environment.
|
||||||
|
* This mock is aliased in vite.config.ts to replace the real module during unit tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const readBarcodesFromImageData = async () => {
|
||||||
|
// Return empty array (no barcodes detected)
|
||||||
|
return [];
|
||||||
|
};
|
||||||
@@ -259,6 +259,50 @@ vi.mock('@google/genai', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocks the barcode service module.
|
||||||
|
* This prevents the dynamic import of zxing-wasm/reader from failing in unit tests.
|
||||||
|
* The zxing-wasm package uses WebAssembly which isn't available in the jsdom test environment.
|
||||||
|
*/
|
||||||
|
vi.mock('../../services/barcodeService.server', () => ({
|
||||||
|
detectBarcode: vi.fn().mockResolvedValue({
|
||||||
|
detected: false,
|
||||||
|
upc_code: null,
|
||||||
|
confidence: null,
|
||||||
|
format: null,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
processBarcodeDetectionJob: vi.fn().mockResolvedValue(undefined),
|
||||||
|
isValidUpcFormat: vi.fn().mockReturnValue(false),
|
||||||
|
calculateUpcCheckDigit: vi.fn().mockReturnValue(null),
|
||||||
|
validateUpcCheckDigit: vi.fn().mockReturnValue(false),
|
||||||
|
detectMultipleBarcodes: vi.fn().mockResolvedValue([]),
|
||||||
|
enhanceImageForDetection: vi.fn().mockImplementation((path: string) => Promise.resolve(path)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocks the client-side config module.
|
||||||
|
* This prevents errors when sentry.client.ts tries to access config.sentry.dsn.
|
||||||
|
*/
|
||||||
|
vi.mock('../../config', () => ({
|
||||||
|
default: {
|
||||||
|
app: {
|
||||||
|
version: '1.0.0-test',
|
||||||
|
commitMessage: 'test commit',
|
||||||
|
commitUrl: 'https://example.com',
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
mapsEmbedApiKey: '',
|
||||||
|
},
|
||||||
|
sentry: {
|
||||||
|
dsn: '',
|
||||||
|
environment: 'test',
|
||||||
|
debug: false,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// FIX: Mock the aiApiClient module as well, which is used by AnalysisPanel
|
// FIX: Mock the aiApiClient module as well, which is used by AnalysisPanel
|
||||||
vi.mock('../../services/aiApiClient', () => ({
|
vi.mock('../../services/aiApiClient', () => ({
|
||||||
// Provide a default implementation that returns a valid Response object to prevent timeouts.
|
// Provide a default implementation that returns a valid Response object to prevent timeouts.
|
||||||
@@ -297,7 +341,32 @@ vi.mock('@bull-board/express', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mocks the logger.
|
* Mocks the Sentry client.
|
||||||
|
* This prevents errors when tests import modules that depend on sentry.client.ts.
|
||||||
|
*/
|
||||||
|
vi.mock('../../services/sentry.client', () => ({
|
||||||
|
isSentryConfigured: false,
|
||||||
|
initSentry: vi.fn(),
|
||||||
|
captureException: vi.fn(),
|
||||||
|
captureMessage: vi.fn(),
|
||||||
|
setUser: vi.fn(),
|
||||||
|
addBreadcrumb: vi.fn(),
|
||||||
|
// Re-export a mock Sentry object for ErrorBoundary and other advanced usage
|
||||||
|
Sentry: {
|
||||||
|
init: vi.fn(),
|
||||||
|
captureException: vi.fn(),
|
||||||
|
captureMessage: vi.fn(),
|
||||||
|
setUser: vi.fn(),
|
||||||
|
setContext: vi.fn(),
|
||||||
|
addBreadcrumb: vi.fn(),
|
||||||
|
withScope: vi.fn(),
|
||||||
|
// Mock the ErrorBoundary component for React
|
||||||
|
ErrorBoundary: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocks the client-side logger.
|
||||||
*/
|
*/
|
||||||
vi.mock('../../services/logger.client', () => ({
|
vi.mock('../../services/logger.client', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -308,6 +377,34 @@ vi.mock('../../services/logger.client', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocks the server-side logger.
|
||||||
|
* This mock provides both `logger` and `createScopedLogger` exports.
|
||||||
|
* Uses vi.hoisted to ensure the mock values are available during module import.
|
||||||
|
* IMPORTANT: Uses import() syntax to ensure correct path resolution for all importers.
|
||||||
|
*/
|
||||||
|
const { mockServerLogger, mockCreateScopedLogger } = vi.hoisted(() => {
|
||||||
|
const mockLogger = {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
trace: vi.fn(),
|
||||||
|
fatal: vi.fn(),
|
||||||
|
child: vi.fn().mockReturnThis(),
|
||||||
|
level: 'debug',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
mockServerLogger: mockLogger,
|
||||||
|
mockCreateScopedLogger: vi.fn(() => mockLogger),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../services/logger.server', () => ({
|
||||||
|
logger: mockServerLogger,
|
||||||
|
createScopedLogger: mockCreateScopedLogger,
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mocks the notification service.
|
* Mocks the notification service.
|
||||||
*/
|
*/
|
||||||
@@ -451,40 +548,57 @@ vi.mock('../../services/db/notification.db', async (importOriginal) => {
|
|||||||
|
|
||||||
// --- Server-Side Service Mocks ---
|
// --- Server-Side Service Mocks ---
|
||||||
|
|
||||||
vi.mock('../../services/aiService.server', async (importOriginal) => {
|
/**
|
||||||
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
|
* Mocks the AI service.
|
||||||
return {
|
* IMPORTANT: This mock does NOT use `importOriginal` because aiService.server has
|
||||||
...actual,
|
* complex dependencies (logger.server, etc.) that cause circular mock resolution issues.
|
||||||
// The singleton instance is named `aiService`. We mock the methods on it.
|
* Instead, we provide a complete mock of the aiService singleton.
|
||||||
aiService: {
|
*/
|
||||||
...actual.aiService, // Spread original methods in case new ones are added
|
vi.mock('../../services/aiService.server', () => ({
|
||||||
extractItemsFromReceiptImage: vi
|
aiService: {
|
||||||
.fn()
|
extractItemsFromReceiptImage: vi
|
||||||
.mockResolvedValue([{ raw_item_description: 'Mock Receipt Item', price_paid_cents: 100 }]),
|
.fn()
|
||||||
extractCoreDataFromFlyerImage: vi.fn().mockResolvedValue({
|
.mockResolvedValue([{ raw_item_description: 'Mock Receipt Item', price_paid_cents: 100 }]),
|
||||||
store_name: 'Mock Store',
|
extractCoreDataFromFlyerImage: vi.fn().mockResolvedValue({
|
||||||
valid_from: '2023-01-01',
|
store_name: 'Mock Store',
|
||||||
valid_to: '2023-01-07',
|
valid_from: '2023-01-01',
|
||||||
store_address: '123 Mock St',
|
valid_to: '2023-01-07',
|
||||||
items: [
|
store_address: '123 Mock St',
|
||||||
{
|
items: [
|
||||||
item: 'Mock Apple',
|
{
|
||||||
price_display: '$1.00',
|
item: 'Mock Apple',
|
||||||
price_in_cents: 100,
|
price_display: '$1.00',
|
||||||
quantity: '1 lb',
|
price_in_cents: 100,
|
||||||
category_name: 'Produce',
|
quantity: '1 lb',
|
||||||
master_item_id: undefined,
|
category_name: 'Produce',
|
||||||
},
|
master_item_id: undefined,
|
||||||
],
|
},
|
||||||
}),
|
],
|
||||||
extractTextFromImageArea: vi.fn().mockImplementation((path, mime, crop, type) => {
|
}),
|
||||||
if (type === 'address') return Promise.resolve({ text: '123 AI Street, Server City' });
|
extractTextFromImageArea: vi.fn().mockImplementation((path, mime, crop, type) => {
|
||||||
return Promise.resolve({ text: 'Mocked Extracted Text' });
|
if (type === 'address') return Promise.resolve({ text: '123 AI Street, Server City' });
|
||||||
}),
|
return Promise.resolve({ text: 'Mocked Extracted Text' });
|
||||||
planTripWithMaps: vi.fn().mockResolvedValue({
|
}),
|
||||||
text: 'Mocked trip plan.',
|
planTripWithMaps: vi.fn().mockResolvedValue({
|
||||||
sources: [{ uri: 'http://maps.google.com/mock', title: 'Mock Map' }],
|
text: 'Mocked trip plan.',
|
||||||
}),
|
sources: [{ uri: 'http://maps.google.com/mock', title: 'Mock Map' }],
|
||||||
},
|
}),
|
||||||
};
|
extractAndValidateData: vi.fn().mockResolvedValue({
|
||||||
});
|
store_name: 'Mock Store',
|
||||||
|
valid_from: '2023-01-01',
|
||||||
|
valid_to: '2023-01-07',
|
||||||
|
store_address: '123 Mock St',
|
||||||
|
items: [],
|
||||||
|
}),
|
||||||
|
isImageAFlyer: vi.fn().mockResolvedValue(true),
|
||||||
|
},
|
||||||
|
// Export the AIService class as a mock constructor for tests that need it
|
||||||
|
AIService: vi.fn().mockImplementation(() => ({
|
||||||
|
extractItemsFromReceiptImage: vi.fn(),
|
||||||
|
extractCoreDataFromFlyerImage: vi.fn(),
|
||||||
|
extractTextFromImageArea: vi.fn(),
|
||||||
|
planTripWithMaps: vi.fn(),
|
||||||
|
extractAndValidateData: vi.fn(),
|
||||||
|
isImageAFlyer: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export default defineConfig({
|
|||||||
// to the browser-safe client version during the Vite build process.
|
// to the browser-safe client version during the Vite build process.
|
||||||
// Server-side code should explicitly import 'services/logger.server'.
|
// Server-side code should explicitly import 'services/logger.server'.
|
||||||
'services/logger': path.resolve(__dirname, './src/services/logger.client.ts'),
|
'services/logger': path.resolve(__dirname, './src/services/logger.client.ts'),
|
||||||
|
// Alias zxing-wasm/reader to a mock to prevent Vite import analysis errors
|
||||||
|
// The actual module uses WebAssembly which doesn't work in jsdom
|
||||||
|
'zxing-wasm/reader': path.resolve(__dirname, './src/tests/mocks/zxing-wasm-reader.mock.ts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -42,6 +45,23 @@ export default defineConfig({
|
|||||||
// The onConsoleLog hook is only needed if you want to conditionally filter specific logs.
|
// The onConsoleLog hook is only needed if you want to conditionally filter specific logs.
|
||||||
// Keeping the default behavior is often safer to avoid missing important warnings.
|
// Keeping the default behavior is often safer to avoid missing important warnings.
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
// Configure dependencies handling for test environment
|
||||||
|
deps: {
|
||||||
|
// Inline the zxing-wasm module to prevent import resolution errors
|
||||||
|
// The module uses dynamic imports and WASM which don't work in jsdom
|
||||||
|
optimizer: {
|
||||||
|
web: {
|
||||||
|
exclude: ['zxing-wasm'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Configure server dependencies
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
// Tell Vitest to not try to resolve these external modules
|
||||||
|
external: ['zxing-wasm', 'zxing-wasm/reader'],
|
||||||
|
},
|
||||||
|
},
|
||||||
globals: true, // tsconfig is auto-detected, so the explicit property is not needed and causes an error.
|
globals: true, // tsconfig is auto-detected, so the explicit property is not needed and causes an error.
|
||||||
globalSetup: './src/tests/setup/global-setup.ts',
|
globalSetup: './src/tests/setup/global-setup.ts',
|
||||||
// The globalApiMock MUST come first to ensure it's applied before other mocks that might depend on it.
|
// The globalApiMock MUST come first to ensure it's applied before other mocks that might depend on it.
|
||||||
|
|||||||
Reference in New Issue
Block a user