Compare commits

..

14 Commits

Author SHA1 Message Date
orangecoding
3ed17f4442 fixing broken puppeteer providers in docker caused by alpine chromium 146 crashing / switched to debian slim with puppeteer's own chrome for testing / dropped 2-stage build / run as non-root / purge build tools after install, improve docker-test.sh to verify it all works. That's it. ;) 2026-03-20 19:19:20 +01:00
orangecoding
b531a7b77a fixing mcp issue, adding claude example 2026-03-20 13:45:42 +01:00
Adrian Bach
3523057221 feat: add smtp adapter (#279) 2026-03-20 11:37:28 +01:00
orangecoding
77311cf39d next release version 2026-03-17 11:26:39 +01:00
orangecoding
556c0aff35 fixing duplicate migration 2026-03-17 11:26:23 +01:00
orangecoding
c40d275e52 cleanup 2026-03-16 14:48:41 +01:00
orangecoding
cbf2766783 cleanup 2026-03-16 14:48:01 +01:00
orangecoding
1b39e345b6 moving from jest to vitest 2026-03-16 14:26:58 +01:00
orangecoding
6ccbdd8afc upgrading dependencies 2026-03-16 10:41:53 +01:00
orangecoding
2a30c89eb2 improving version banner 2026-03-16 10:37:36 +01:00
orangecoding
4878dc98e3 Merge branch 'master' of github.com:orangecoding/fredy 2026-03-11 15:26:56 +01:00
orangecoding
dc2704997d upgrading dependencies 2026-03-11 15:26:25 +01:00
orangecoding
e107b0fb00 next release version 2026-03-11 15:25:20 +01:00
Promises
6c08675fee Add new properties to real estate translation mappings (#275)
Added few more properties for buying a house
2026-03-11 14:49:21 +01:00
43 changed files with 1501 additions and 1344 deletions

View File

@@ -1,70 +1,59 @@
# ================================
# Stage 1: Build stage
# ================================
FROM node:22-alpine AS builder
FROM node:22-slim
WORKDIR /build
# Install build dependencies needed for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# Copy package files first for better layer caching
COPY package.json yarn.lock ./
# Install all dependencies (including devDependencies for building)
RUN yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile
# Copy source files needed for build
COPY index.html vite.config.js ./
COPY ui ./ui
COPY lib ./lib
# Build frontend assets
RUN yarn build:frontend
# ================================
# Stage 2: Production stage
# ================================
FROM node:22-alpine
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
# Must run as root
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates fonts-liberation libasound2 \
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /db /conf /fredy \
&& chown node:node /db /conf /fredy
WORKDIR /fredy
# Install Chromium and curl (for healthcheck)
# Using Alpine's chromium package which is much smaller
RUN apk add --no-cache chromium curl
# Everything from here runs as the built-in non-root node user (UID 1000)
USER node
ENV NODE_ENV=production \
IS_DOCKER=true \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
IS_DOCKER=true
# Install build dependencies for native modules, then remove them after yarn install
COPY package.json yarn.lock ./
COPY --chown=node:node package.json yarn.lock ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile --production \
&& yarn cache clean \
&& apk del .build-deps
# Install dependencies and purge build tools (only needed to compile better-sqlite3)
RUN yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile \
&& yarn cache clean
# Copy built frontend from builder stage
COPY --from=builder /build/ui/public ./ui/public
# Install Chrome for Testing in a separate layer — it's ~150MB and rarely changes,
# so keeping it separate avoids re-downloading on every code/dependency change
RUN npx puppeteer browsers install chrome
# Copy application source (only what's needed at runtime)
COPY index.js ./
COPY index.html ./
COPY lib ./lib
# Purge build tools now that native modules are compiled
USER root
RUN apt-get purge -y python3 make g++ \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
USER node
# Prepare runtime directories and symlinks for data and config
RUN mkdir -p /db /conf \
&& chown 1000:1000 /db /conf \
&& chmod 777 /db /conf \
&& ln -s /db /fredy/db \
COPY --chown=node:node index.html vite.config.js ./
COPY --chown=node:node ui ./ui
COPY --chown=node:node lib ./lib
RUN yarn build:frontend
COPY --chown=node:node index.js ./
RUN ln -s /db /fredy/db \
&& ln -s /conf /fredy/conf
EXPOSE 9998
VOLUME /db
VOLUME /conf
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:9998/ || exit 1
CMD ["node", "index.js"]

View File

@@ -7,12 +7,63 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
docker rm fredy || true
fi
# On Apple Silicon, force linux/amd64 to match production CI and avoid arm64/x86_64
# Chrome mismatch under Rosetta. On native Linux (amd64 or arm64) let Docker pick naturally. That took me fucking 1 hour to figure out.
PLATFORM=""
if [ "$(uname -m)" = "arm64" ] && [ "$(uname -s)" = "Darwin" ]; then
PLATFORM="linux/amd64"
fi
# Build image from local Dockerfile, forcing a fresh build without cache
docker build --no-cache -t fredy:local .
if [ -n "$PLATFORM" ]; then
docker build --no-cache --platform "$PLATFORM" -t fredy:local .
else
docker build --no-cache -t fredy:local .
fi
# Run container with volumes and port mapping
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
fredy:local
if [ -n "$PLATFORM" ]; then
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
else
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
fi
echo "Waiting for app to be ready..."
for i in $(seq 1 30); do
if docker exec fredy curl -sf http://localhost:9998/ > /dev/null 2>&1; then
echo "App is up"
break
fi
if [ "$i" = "30" ]; then
echo "App did not come up in time"
docker logs fredy
exit 1
fi
sleep 2
done
# Verify the process is NOT running as root
RUNNING_USER=$(docker exec fredy id -u)
if [ "$RUNNING_USER" = "0" ]; then
echo "Process is running as root!"
exit 1
fi
echo "Process runs as UID $RUNNING_USER (not root)"
# Verify Chrome launches without crashing
echo "Testing Chrome..."
CHROME=$(docker exec fredy find /home/node/.cache/puppeteer -name chrome -type f 2>/dev/null | head -1)
if [ -z "$CHROME" ]; then
echo "Chrome binary not found"
exit 1
fi
if docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | grep -q "<html"; then
echo "Chrome works"
else
echo "Chrome failed to render a page"
docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | head -20
exit 1
fi
echo ""
echo "All checks passed."

View File

@@ -25,12 +25,15 @@ export default [
globals: {
...globals.browser,
...globals.node,
...globals.mocha,
...globals.jest,
Promise: 'readonly',
fetch: 'readonly',
describe: 'readonly',
after: 'readonly',
it: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
vi: 'readonly',
},
},
plugins: { react },

View File

@@ -1,4 +1,4 @@
# Fredy MCP Server
# Fredy MCP Server
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
@@ -126,6 +126,54 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
> **Tip:** Make sure Fredy is running and the database is accessible before starting the MCP server in LM Studio. The stdio transport initializes its own database connection, so Fredy's main process does not need to be running, but the database file must exist and be up-to-date (migrations applied).
### Claude Desktop Configuration
[Claude Desktop](https://claude.ai/download) supports MCP servers natively via its developer settings.
#### Setup
1. Open **Claude Desktop**
2. Go to **Settings → Developer → Edit Config** — this opens the `claude_desktop_config.json` file
3. Add the `fredy` server to the `mcpServers` object:
```json
{
"mcpServers": {
"fredy": {
"command": "/opt/homebrew/opt/node@22/bin/node",
"args": ["/absolute/path/to/fredy/lib/mcp/stdio.js"],
"env": {
"MCP_TOKEN": "fredy_<your-token>"
}
}
}
}
```
Replace `/absolute/path/to/fredy` with the actual path on your machine (e.g. `/Users/you/dev/fredy`).
> **Important:** Claude Desktop launches with a restricted `PATH` and often cannot find `node` by name. Always use the **full absolute path** to the node binary. Find yours by running `which node` in a terminal. Common locations:
> - Homebrew (default): `/opt/homebrew/bin/node`
> - Homebrew (versioned, e.g. node@22): `/opt/homebrew/opt/node@22/bin/node`
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
4. Save the file and **restart Claude Desktop**
5. You should see a hammer icon (🔨) in the chat input — click it to confirm the Fredy tools are listed
#### Usage
Once connected, simply ask Claude about your real estate data:
- *"Show me all my active search jobs"*
- *"List the latest listings from my Berlin apartment search"*
- *"What are the cheapest apartments added this week?"*
Claude will automatically call the appropriate Fredy MCP tools.
> **Note:** Fredy's main web process does not need to be running — the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
---
## Usage with Remote LLM (Streamable HTTP transport)
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import nodemailer from 'nodemailer';
import path from 'path';
import fs from 'fs';
import Handlebars from 'handlebars';
import { markdown2Html } from '../../services/markdown.js';
import { getDirName, normalizeImageUrl } from '../../utils.js';
const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings) =>
listings.map((l) => {
const image = normalizeImageUrl(l.image);
return {
title: l.title || '',
link: l.link || '',
address: l.address || '',
size: l.size || '',
price: l.price || '',
image,
hasImage: Boolean(image),
serviceName,
jobKey,
};
});
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
(adapter) => adapter.id === config.id,
).fields;
const to = receiver
.trim()
.split(',')
.map((r) => r.trim())
.filter(Boolean);
const transporter = nodemailer.createTransport({
host,
port: Number(port),
secure: secure === 'true',
auth: {
user: username,
pass: password,
},
});
const listings = mapListings(serviceName, jobKey, newListings);
const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
numberOfListings: listings.length,
listings,
});
return transporter.sendMail({
from,
to: to.join(','),
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
html,
});
};
export const config = {
id: 'smtp',
name: 'SMTP',
description: 'Send notifications via any SMTP server using Nodemailer.',
readme: markdown2Html('lib/notification/adapter/smtp.md'),
fields: {
host: {
type: 'text',
label: 'SMTP Host',
description: 'The hostname of the SMTP server (e.g., smtp.gmail.com).',
},
port: {
type: 'text',
label: 'SMTP Port',
description: 'The port of the SMTP server (e.g., 587 for STARTTLS, 465 for SSL).',
},
secure: {
type: 'text',
label: 'Secure (SSL/TLS)',
description: 'Set to "true" for port 465 (SSL). Leave empty or "false" for STARTTLS on port 587.',
},
username: {
type: 'text',
label: 'Username',
description: 'The username for SMTP authentication.',
},
password: {
type: 'text',
label: 'Password',
description: 'The password (or app password) for SMTP authentication.',
},
receiver: {
type: 'text',
label: 'Receiver Email(s)',
description: 'Comma-separated email addresses Fredy will send notifications to.',
},
from: {
type: 'email',
label: 'Sender Email',
description: 'The email address Fredy sends from.',
},
},
};

View File

@@ -0,0 +1,22 @@
### SMTP Adapter
Send notifications through any SMTP server using [Nodemailer](https://nodemailer.com/).
This works with Gmail, Outlook, self-hosted mail servers, or any provider that supports SMTP.
Setup:
- Provide the SMTP host and port of your mail server.
- For **SSL/TLS** (port 465), set Secure to `true`.
- For **STARTTLS** (port 587), leave Secure empty or set it to `false`.
- Enter the username and password for authentication. For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833).
- Set the sender email address (must be allowed by your SMTP server).
Multiple recipients:
- Separate email addresses with commas (e.g., `some@email.com`, `someOther@email.com`).
Common SMTP settings:
- **Gmail** — `smtp.gmail.com`, port 587, secure: false
- **Outlook** — `smtp.office365.com`, port 587, secure: false
- **Yahoo** — `smtp.mail.yahoo.com`, port 465, secure: true

View File

@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
price: 'price',
constructionyear: 'constructionyear',
apartmenttypes: 'apartmenttypes',
buildingtypes: 'buildingtypes',
ground: 'ground',
pricetype: 'pricetype',
floor: 'floor',
geocodes: 'geocodes',
@@ -98,6 +100,7 @@ const EQUIPMENT_MAP = {
guesttoilet: 'guestToilet',
balcony: 'balcony',
handicappedaccessible: 'handicappedAccessible',
lodgerflat: 'lodgerflat',
};
const REAL_ESTATE_TYPE = {
@@ -107,6 +110,10 @@ const REAL_ESTATE_TYPE = {
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
'eigentumswohnung-mit-garten': 'apartmentbuy',
'haus-kaufen': 'housebuy',
'haus-mit-keller-kaufen': 'housebuy',
'luxushaus-kaufen': 'housebuy',
'villa-kaufen': 'housebuy',
'neubauhaus-kaufen': 'housebuy',
};
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {

View File

@@ -29,12 +29,12 @@
*/
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
import { pathToFileURL, fileURLToPath } from 'url';
import crypto from 'crypto';
import SqliteConnection from '../SqliteConnection.js';
import logger from '../../logger.js';
const ROOT = path.resolve('.');
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
/**
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
* @type {string}

View File

@@ -13,7 +13,10 @@ import crypto from 'crypto';
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
// Tokens are auto-generated for all existing users during this migration.
export function up(db) {
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
const columns = db.prepare(`PRAGMA table_info(users)`).all();
if (!columns.some((col) => col.name === 'mcp_token')) {
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
}
// Backfill all existing users that don't have a token yet
const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "20.0.2",
"version": "20.0.7",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -11,8 +11,8 @@
"build:frontend": "vite build",
"format": "prettier --write \"**/*.js\"",
"format:check": "prettier --check \"**/*.js\"",
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
"test": "vitest run",
"testGH": "vitest run --config vitest.gh.config.js",
"lint": "eslint .",
"mcp:stdio": "node lib/mcp/stdio.js",
"lint:fix": "yarn lint --fix",
@@ -61,30 +61,31 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-icons": "^2.92.2",
"@douyinfe/semi-ui": "2.92.2",
"@douyinfe/semi-ui-19": "^2.92.2",
"@douyinfe/semi-icons": "^2.93.0",
"@douyinfe/semi-ui": "2.93.0",
"@douyinfe/semi-ui-19": "^2.93.0",
"@mapbox/mapbox-gl-draw": "^1.5.1",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.4",
"@modelcontextprotocol/sdk": "^1.27.1",
"@sendgrid/mail": "8.1.6",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@vitejs/plugin-react": "6.0.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.2",
"better-sqlite3": "^12.8.0",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
"@turf/boolean-point-in-polygon": "^7.3.4",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.23",
"maplibre-gl": "^5.19.0",
"nanoid": "5.1.6",
"maplibre-gl": "^5.20.2",
"nanoid": "5.1.7",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
"nodemailer": "^8.0.3",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.38.0",
"puppeteer": "^24.40.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
@@ -94,34 +95,32 @@
"react-range-slider-input": "^3.3.2",
"react-router": "7.13.1",
"react-router-dom": "7.13.1",
"resend": "^6.9.3",
"resend": "^6.9.4",
"restana": "5.1.0",
"semver": "^7.7.4",
"serve-static": "2.2.1",
"slack": "11.0.2",
"vite": "7.3.1",
"vite": "8.0.1",
"x-var": "^3.0.1",
"zustand": "^5.0.11"
"zustand": "^5.0.12"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/eslint-parser": "7.28.6",
"@babel/preset-env": "7.29.0",
"@babel/preset-env": "7.29.2",
"@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1",
"chai": "6.2.2",
"chalk": "^5.6.2",
"eslint": "10.0.3",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"esmock": "2.7.3",
"globals": "^17.4.0",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.5.1",
"lint-staged": "16.3.3",
"mocha": "11.7.5",
"less": "4.6.4",
"lint-staged": "16.4.0",
"nodemon": "^3.1.14",
"prettier": "3.8.1"
"prettier": "3.8.1",
"vitest": "^4.1.0"
}
}

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import esmock from 'esmock';
import { vi, describe, it, expect, beforeEach } from 'vitest';
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
let svc;
@@ -14,7 +13,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
beforeEach(async () => {
calls = { logger: { info: [], warn: [], error: [] } };
// Mock AdmZip with configurable state via globalThis (avoid esmock export name pitfalls)
// Mock AdmZip with configurable state via globalThis (avoid mock export name pitfalls)
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
setZipState = (s) => {
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
@@ -77,67 +76,61 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
const utilsMock = { getPackageVersion: async () => '16.2.0' };
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
const mod = await esmock(
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
{},
{
'adm-zip': admZipMock,
[admZipPath]: admZipMock,
[migratePath]: migrateMock,
[sqlitePath]: sqliteMock,
[loggerPath]: loggerMock,
[utilsPath]: utilsMock,
},
);
vi.resetModules();
vi.doMock('adm-zip', () => admZipMock);
vi.doMock(migratePath, () => migrateMock);
vi.doMock(sqlitePath, () => sqliteMock);
vi.doMock(loggerPath, () => loggerMock);
vi.doMock(utilsPath, () => utilsMock);
const mod = await import(path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'));
svc = mod;
});
it('precheck: empty upload yields danger', async () => {
const res = await svc.precheckRestore(Buffer.alloc(0));
expect(res.compatible).to.equal(false);
expect(res.severity).to.equal('danger');
expect(res.message).to.contain('Empty upload');
expect(res.requiredMigration).to.equal(10);
expect(res.compatible).toBe(false);
expect(res.severity).toBe('danger');
expect(res.message).toContain('Empty upload');
expect(res.requiredMigration).toBe(10);
});
it('precheck: missing listings.db yields danger', async () => {
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
const res = await svc.precheckRestore(Buffer.from('dummy'));
expect(res.compatible).to.equal(false);
expect(res.severity).to.equal('danger');
expect(res.message).to.match(/missing the database file/i);
expect(res.compatible).toBe(false);
expect(res.severity).toBe('danger');
expect(res.message).toMatch(/missing the database file/i);
});
it('precheck: older backup is compatible with warning', async () => {
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(true);
expect(res.severity).to.equal('warning');
expect(res.message).to.match(/automatic migrations/i);
expect(res.backupMigration).to.equal(5);
expect(res.requiredMigration).to.equal(10);
expect(res.compatible).toBe(true);
expect(res.severity).toBe('warning');
expect(res.message).toMatch(/automatic migrations/i);
expect(res.backupMigration).toBe(5);
expect(res.requiredMigration).toBe(10);
});
it('precheck: equal backup is compatible with info', async () => {
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(true);
expect(res.severity).to.equal('info');
expect(res.compatible).toBe(true);
expect(res.severity).toBe('info');
});
it('precheck: newer backup yields danger', async () => {
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(false);
expect(res.severity).to.equal('danger');
expect(res.compatible).toBe(false);
expect(res.severity).toBe('danger');
});
it('buildBackupFileName: matches pattern and includes version', async () => {
const name = await svc.buildBackupFileName();
expect(name).to.match(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
expect(name).to.include('16.2.0');
expect(name).to.match(/\.zip$/);
expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
expect(name).toContain('16.2.0');
expect(name).toMatch(/\.zip$/);
});
});

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import esmock from 'esmock';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
@@ -85,22 +84,18 @@ describe('db/migrations/migrate.js - runMigrations', () => {
},
};
// esmock with dependency replacements
const path = await import('node:path');
const ROOT = path.resolve('.');
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
const mod = await esmock(
'../../../db/migrations/migrate.js',
{},
{
fs: fsMock,
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
vi.resetModules();
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
vi.doMock(sqlPath, () => ({ default: sqlMock }));
vi.doMock(loggerPath, () => ({ default: loggerMock }));
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations;
// remember original exitCode to restore later
@@ -114,9 +109,9 @@ describe('db/migrations/migrate.js - runMigrations', () => {
it('logs and returns when no migration files are found', async () => {
await runMigrations();
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true);
expect(calls.sql.getConnection).to.equal(0);
expect(calls.sql.optimize).to.equal(0);
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).toBe(true);
expect(calls.sql.getConnection).toBe(0);
expect(calls.sql.optimize).toBe(0);
});
it('applies a single new migration inside a transaction and records it', async () => {
@@ -165,11 +160,6 @@ describe('db/migrations/migrate.js - runMigrations', () => {
},
};
// We need to intercept dynamic import by esmock: provide a stub for import(url)
// esmock supports mocking via a virtual module using URL matching, but simpler approach:
// place the file path that migrate.js will compute and make Node import resolve to our stub
// We simulate by mocking url.pathToFileURL is still used, but dynamic import will be handled by esmock when we map the computed path.
const path = await import('node:path');
const ROOT = path.resolve('.');
@@ -178,26 +168,22 @@ describe('db/migrations/migrate.js - runMigrations', () => {
// Use global importer hook to bypass dynamic import
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
const mod = await esmock(
'../../../db/migrations/migrate.js',
{},
{
fs: fsMock,
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
vi.resetModules();
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
vi.doMock(sqlPath, () => ({ default: sqlMock }));
vi.doMock(loggerPath, () => ({ default: loggerMock }));
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations;
await runMigrations();
// Should have started a transaction and inserted into schema_migrations
expect(calls.sql.withTransaction.length).to.equal(1);
expect(calls.sql.withTransaction.length).toBe(1);
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
expect(!!inserted).to.equal(true);
expect(calls.sql.optimize).to.equal(1);
expect(!!inserted).toBe(true);
expect(calls.sql.optimize).toBe(1);
});
it('skips already executed migration with same checksum', async () => {
@@ -242,24 +228,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
const mod = await esmock(
'../../../db/migrations/migrate.js',
{},
{
fs: fsMock,
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
vi.resetModules();
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
vi.doMock(sqlPath, () => ({ default: sqlMock }));
vi.doMock(loggerPath, () => ({ default: loggerMock }));
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations;
await runMigrations();
// Should not run transaction because it's skipped
expect(calls.sql.withTransaction.length).to.equal(0);
expect(calls.sql.optimize).to.equal(1);
expect(calls.sql.withTransaction.length).toBe(0);
expect(calls.sql.optimize).toBe(1);
});
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
@@ -311,24 +293,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
const mod = await esmock(
'../../../lib/services/storage/migrations/migrate.js',
{},
{
fs: fsMock,
crypto: cryptoMock,
[sqlPath]: sqlMock,
[loggerPath]: loggerMock,
},
);
vi.resetModules();
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
vi.doMock(sqlPath, () => ({ default: sqlMock }));
vi.doMock(loggerPath, () => ({ default: loggerMock }));
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
runMigrations = mod.runMigrations;
await runMigrations();
expect(process.exitCode).to.equal(1);
expect(process.exitCode).toBe(1);
// No insert into schema_migrations should be recorded since transaction failed
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
expect(inserted).to.equal(undefined);
expect(inserted).toBe(undefined);
});
});

View File

@@ -1,4 +0,0 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('esmock', pathToFileURL('./'));

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import { expect } from 'vitest';
import { mockFredy } from './utils.js';
import * as mockStore from './mocks/mockStore.js';
@@ -34,7 +34,7 @@ describe('Issue reproduction: listings filtered by similarity or area should be
// Might throw NoNewListingsWarning if all are filtered out
}
expect(mockStore.deletedIds).to.include('1');
expect(mockStore.deletedIds).toContain('1');
});
it('should call deleteListingsById when listings are filtered by area', async () => {
@@ -84,6 +84,6 @@ describe('Issue reproduction: listings filtered by similarity or area should be
// Might throw NoNewListingsWarning if all are filtered out
}
expect(mockStore.deletedIds).to.include('2');
expect(mockStore.deletedIds).toContain('2');
});
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/einsAImmobilien.js';
describe('#einsAImmobilien testsuite()', () => {
@@ -23,22 +23,22 @@ describe('#einsAImmobilien testsuite()', () => {
similarityCache,
);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('einsAImmobilien');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).to.be.not.empty;
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de');
expect(notify.size).not.toBe('');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.1a-immobilienmarkt.de');
});
resolve();
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/immobilienDe.js';
describe('#immobilien.de testsuite()', () => {
@@ -16,24 +16,24 @@ describe('#immobilien.de testsuite()', () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immobilienDe');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immobilienDe');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.price).that.does.include('€');
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.immobilien.de');
expect(notify.address).to.be.not.empty;
expect(notify.price).toContain('€');
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immobilien.de');
expect(notify.address).not.toBe('');
});
resolve();
});

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import { expect } from 'vitest';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { mockFredy, providerConfig } from '../utils.js';
import { get } from '../mocks/mockNotification.js';
@@ -16,22 +16,22 @@ describe('#immoscout provider testsuite()', () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immoscout');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immoscout');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).to.be.not.empty;
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.immobilienscout24.de/');
expect(notify.size).not.toBe('');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immobilienscout24.de/');
});
resolve();
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/immoswp.js';
describe('#immoswp testsuite()', () => {
@@ -16,21 +16,21 @@ describe('#immoswp testsuite()', () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immoswp');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immoswp');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.price).that.does.include('€');
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://immo.swp.de');
expect(notify.price).toContain('€');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://immo.swp.de');
});
resolve();
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/immowelt.js';
describe('#immowelt testsuite()', () => {
@@ -17,24 +17,24 @@ describe('#immowelt testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immowelt');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immowelt');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
expect(notify.size).that.does.include('m²');
expect(notify.size).toContain('m²');
}
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.immowelt.de');
expect(notify.address).to.be.not.empty;
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immowelt.de');
expect(notify.address).not.toBe('');
});
});
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/kleinanzeigen.js';
describe('#kleinanzeigen testsuite()', () => {
@@ -23,20 +23,20 @@ describe('#kleinanzeigen testsuite()', () => {
similarityCache,
);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('kleinanzeigen');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
expect(notify.address).to.be.not.empty;
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.kleinanzeigen.de');
expect(notify.address).not.toBe('');
});
resolve();
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/mcMakler.js';
describe('#mcMakler testsuite()', () => {
@@ -17,22 +17,22 @@ describe('#mcMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('mcMakler');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('mcMakler');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});
});
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/neubauKompass.js';
describe('#neubauKompass testsuite()', () => {
@@ -23,20 +23,20 @@ describe('#neubauKompass testsuite()', () => {
similarityCache,
);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).to.equal('neubauKompass');
expect(notificationObj.serviceName).toBe('neubauKompass');
notificationObj.payload.forEach((notify) => {
expect(notify).to.be.a('object');
expect(notify).toBeTypeOf('object');
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.neubaukompass.de');
expect(notify.address).to.be.not.empty;
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.neubaukompass.de');
expect(notify.address).not.toBe('');
});
resolve();
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/ohneMakler.js';
describe('#ohneMakler testsuite()', () => {
@@ -17,22 +17,22 @@ describe('#ohneMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('ohneMakler');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('ohneMakler');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});
});
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/regionalimmobilien24.js';
describe('#regionalimmobilien24 testsuite()', () => {
@@ -24,22 +24,22 @@ describe('#regionalimmobilien24 testsuite()', () => {
);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('regionalimmobilien24');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});
});
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/sparkasse.js';
describe('#sparkasse testsuite()', () => {
@@ -17,22 +17,21 @@ describe('#sparkasse testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('sparkasse');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('sparkasse');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});
});
});

View File

@@ -41,7 +41,7 @@
"enabled": true
},
"sparkasse": {
"url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg",
"url": "https://immobilien.sparkasse.de/immobilien/treffer?estateTypeGroupingId=403&marketingType=buy&perimeter=10&usageType=residential&zipCityEstateId=51.22422%2F6.78006%2F0__D%C3%BCsseldorf",
"enabled": true
},
"wgGesucht": {

View File

@@ -5,7 +5,7 @@
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
import assert from 'assert';
import { expect } from 'chai';
import { expect } from 'vitest';
const fakeWorkingHoursConfig = (from, to) => ({
workingHours: {
@@ -25,19 +25,19 @@ describe('utils', () => {
});
describe('#duringWorkingHoursOrNotSet()', () => {
it('should be false', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).toBe(false);
});
it('should be true', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).toBe(true);
});
it('should be true if nothing set', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).toBe(true);
});
it('should be true if only to is set', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).toBe(true);
});
it('should be true if only from is set', () => {
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).toBe(true);
});
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
@@ -49,9 +49,9 @@ describe('utils', () => {
d.setMilliseconds(0);
return d.getTime();
};
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).toBe(true); // 23:00 => within window
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).toBe(false); // 01:00 => outside window
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).toBe(true); // 06:00 => within window
});
});
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/wgGesucht.js';
describe('#wgGesucht testsuite()', () => {
@@ -16,17 +16,17 @@ describe('#wgGesucht testsuite()', () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).to.equal('wgGesucht');
expect(notificationObj.serviceName).toBe('wgGesucht');
notificationObj.payload.forEach((notify) => {
expect(notify).to.be.a('object');
expect(notify).toBeTypeOf('object');
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.details).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.details).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
});
resolve();
});

View File

@@ -6,7 +6,7 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import * as provider from '../../lib/provider/wohnungsboerse.js';
describe('#wohnungsboerse testsuite()', () => {
@@ -23,22 +23,22 @@ describe('#wohnungsboerse testsuite()', () => {
similarityCache,
);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('wohnungsboerse');
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('wohnungsboerse');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).to.be.not.empty;
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.wohnungsboerse.net');
expect(notify.size).not.toBe('');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.wohnungsboerse.net');
});
resolve();
});

View File

@@ -4,7 +4,7 @@
*/
import fs from 'fs';
import { expect } from 'chai';
import { expect } from 'vitest';
import { readFile } from 'fs/promises';
import mutator from '../../lib/services/queryStringMutator.js';
import queryString from 'query-string';
@@ -33,8 +33,8 @@ describe('queryStringMutator', () => {
const expectedParams = queryString.parseUrl(test.shouldBecome);
const actualParams = queryString.parseUrl(fixedUrl);
//check if all new params are existing
expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query));
expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query));
expect(Object.keys(expectedParams.query)).toEqual(expect.arrayContaining(Object.keys(actualParams.query)));
expect(Object.values(expectedParams.query)).toEqual(expect.arrayContaining(Object.values(actualParams.query)));
}
});
});

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { expect } from 'vitest';
import {
getPreLaunchConfig,
@@ -26,16 +25,16 @@ describe('botPrevention helper', () => {
};
const cfg = getPreLaunchConfig(url, options);
expect(cfg.acceptLanguage).to.equal('de-DE,de;q=0.9');
expect(cfg.langArg).to.equal('--lang=de-DE');
expect(cfg.windowSizeArg).to.equal('--window-size=1200,700');
expect(cfg.viewport).to.deep.equal({ width: 1200, height: 700, deviceScaleFactor: 2 });
expect(cfg.userAgent).to.equal('TestAgent/1.0');
expect(cfg.headers['Accept-Language']).to.equal('de-DE,de;q=0.9');
expect(cfg.headers['User-Agent']).to.equal('TestAgent/1.0');
expect(cfg.headers.Referer).to.equal('https://example.com/ref');
expect(cfg.extraArgs).to.include('--disable-blink-features=AutomationControlled');
expect(cfg.extraArgs).to.include('--proxy-bypass-list=<-loopback>');
expect(cfg.acceptLanguage).toBe('de-DE,de;q=0.9');
expect(cfg.langArg).toBe('--lang=de-DE');
expect(cfg.windowSizeArg).toBe('--window-size=1200,700');
expect(cfg.viewport).toEqual({ width: 1200, height: 700, deviceScaleFactor: 2 });
expect(cfg.userAgent).toBe('TestAgent/1.0');
expect(cfg.headers['Accept-Language']).toBe('de-DE,de;q=0.9');
expect(cfg.headers['User-Agent']).toBe('TestAgent/1.0');
expect(cfg.headers.Referer).toBe('https://example.com/ref');
expect(cfg.extraArgs).toContain('--disable-blink-features=AutomationControlled');
expect(cfg.extraArgs).toContain('--proxy-bypass-list=<-loopback>');
});
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
@@ -58,15 +57,15 @@ describe('botPrevention helper', () => {
await applyBotPreventionToPage(page, cfg);
expect(calls[0]).to.deep.equal(['setUserAgent', 'Foo/Bar']);
expect(calls.some((c) => c[0] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).to.equal(true);
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).to.equal(true);
expect(calls[0]).toEqual(['setUserAgent', 'Foo/Bar']);
expect(calls.some((c) => c[0] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).toBe(true);
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).toBe(true);
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
expect(headerCall).to.exist;
expect(headerCall[1]['Accept-Language']).to.equal('en-US,en');
expect(headerCall[1]['User-Agent']).to.equal('Foo/Bar');
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).to.equal(true);
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).to.equal(true);
expect(headerCall).toBeDefined();
expect(headerCall[1]['Accept-Language']).toBe('en-US,en');
expect(headerCall[1]['User-Agent']).toBe('Foo/Bar');
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).toBe(true);
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).toBe(true);
});
it('applyLanguagePersistence stores languages early', async () => {
@@ -80,9 +79,9 @@ describe('botPrevention helper', () => {
});
await applyLanguagePersistence(page, cfg);
const call = calls[0];
expect(call[0]).to.equal('evaluateOnNewDocument');
expect(call[1]).to.equal('function');
expect(call[2]).to.equal('de-DE,de');
expect(call[0]).toBe('evaluateOnNewDocument');
expect(call[1]).toBe('function');
expect(call[2]).toBe('de-DE,de');
});
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
@@ -98,7 +97,7 @@ describe('botPrevention helper', () => {
viewport: { width: 1200, height: 800 },
};
await applyPostNavigationHumanSignals(page, cfg);
expect(mouseCalls.some((c) => c[0] === 'move')).to.equal(true);
expect(mouseCalls.some((c) => c[0] === 'wheel')).to.equal(true);
expect(mouseCalls.some((c) => c[0] === 'move')).toBe(true);
expect(mouseCalls.some((c) => c[0] === 'wheel')).toBe(true);
});
});

View File

@@ -4,7 +4,7 @@
*/
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
import { expect } from 'chai';
import { expect } from 'vitest';
import { readFile } from 'fs/promises';
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
@@ -18,7 +18,7 @@ describe('#immoscout-mobile URL conversion', () => {
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
const actualMobileUrl = convertWebToMobile(webUrl);
expect(actualMobileUrl).to.equal(expectedMobileUrl);
expect(actualMobileUrl).toBe(expectedMobileUrl);
});
// Test URL conversion of web-only SEO path
@@ -27,27 +27,27 @@ describe('#immoscout-mobile URL conversion', () => {
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('equipment').split(',')).to.include.members(['garden', 'balcony']);
expect(queryParams.get('equipment').split(',')).toEqual(expect.arrayContaining(['garden', 'balcony']));
});
// Test URL conversion with unsupported query parameters
it('should remove unsupported query parameters', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
const converted = convertWebToMobile(webUrl);
expect(converted).that.does.not.include('minimuminternetspeed');
expect(converted).not.toContain('minimuminternetspeed');
});
// Test URL conversion with invalid URL
it('should throw an error for invalid URL', () => {
const invalidUrl = 'invalid-url';
expect(() => convertWebToMobile(invalidUrl)).to.throw('Invalid URL: invalid-url');
expect(() => convertWebToMobile(invalidUrl)).toThrow('Invalid URL: invalid-url');
});
// Test URL conversion with unexpected path format
it('should throw an error for unexpected path format', () => {
const webUrl = 'https://www.immobilienscout24.de/invalid/path/format';
expect(() => convertWebToMobile(webUrl)).to.throw('Unexpected path format: /invalid/path/format');
expect(() => convertWebToMobile(webUrl)).toThrow('Unexpected path format: /invalid/path/format');
});
it('shouldFindResultsForEveryTestData', async () => {
@@ -70,14 +70,12 @@ describe('#immoscout-mobile URL conversion', () => {
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
}
expect([null, true]).to.include(response.ok);
expect([null, true]).toContain(response.ok);
const responseBody = await response.json();
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.resultListItems.length).to.greaterThan(0);
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
type,
);
expect(responseBody.totalResults).toBeGreaterThan(0);
expect(responseBody.totalResults).toBeGreaterThan(0);
expect(responseBody.resultListItems.length).toBeGreaterThan(0);
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).toBe(type);
}
});
});

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import esmock from 'esmock';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { EventEmitter } from 'node:events';
describe('services/jobs/jobExecutionService', () => {
@@ -22,45 +21,39 @@ describe('services/jobs/jobExecutionService', () => {
const brokerPath = root + '/lib/services/sse/sse-broker.js';
const utilsPath = root + '/lib/utils.js';
const loggerPath = root + '/lib/services/logger.js';
const notifyPath = root + '/lib/notification/notify.js';
// esmock the service with all its collaborators
const mod = await esmock(
svcPath,
{},
{
[busPath]: { bus },
[jobStoragePath]: {
getJob: (id) => state.jobsById[id] || null,
getJobs: () => state.jobsList.slice(),
},
[userStoragePath]: {
getUsers: () => state.users.slice(),
getUser: (id) => state.users.find((u) => u.id === id) || null,
},
[brokerPath]: {
sendToUsers: (...args) => calls.sent.push(args),
},
[utilsPath]: {
duringWorkingHoursOrNotSet: () => false, // avoid startup run
},
[loggerPath]: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
[root + '/lib/services/jobs/run-state.js']: {
isRunning: () => false,
markRunning: (id) => {
calls.markRunning.push(id);
return true;
},
markFinished: () => {},
},
vi.resetModules();
vi.doMock(busPath, () => ({ bus }));
vi.doMock(jobStoragePath, () => ({
getJob: (id) => state.jobsById[id] || null,
getJobs: () => state.jobsList.slice(),
}));
vi.doMock(userStoragePath, () => ({
getUsers: () => state.users.slice(),
getUser: (id) => state.users.find((u) => u.id === id) || null,
}));
vi.doMock(brokerPath, () => ({
sendToUsers: (...args) => calls.sent.push(args),
}));
vi.doMock(utilsPath, () => ({
duringWorkingHoursOrNotSet: () => false,
}));
vi.doMock(loggerPath, () => {
const m = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
return { default: m };
});
vi.doMock(notifyPath, () => ({ send: async () => [] }));
vi.doMock(root + '/lib/services/jobs/run-state.js', () => ({
isRunning: () => false,
markRunning: (id) => {
calls.markRunning.push(id);
return true;
},
);
markFinished: () => {},
}));
// call initializer with minimal deps
const mod = await import(svcPath);
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
return mod;
}
@@ -87,13 +80,13 @@ describe('services/jobs/jobExecutionService', () => {
bus.emit('jobs:status', { jobId: 'j1', running: true });
expect(calls.sent.length).to.equal(1, 'sendToUsers should be called once');
expect(calls.sent.length, 'sendToUsers should be called once').toBe(1);
const [recipients, event, data] = calls.sent[0];
expect(event).to.equal('jobStatus');
expect(data).to.deep.equal({ jobId: 'j1', running: true });
expect(event).toBe('jobStatus');
expect(data).toEqual({ jobId: 'j1', running: true });
const got = new Set(recipients);
const expected = new Set(['owner1', 'u2', 'a1']);
expect(got).to.deep.equal(expected);
expect(got).toEqual(expected);
});
it('runs all jobs for admin; only own jobs for regular user', async () => {
@@ -113,12 +106,12 @@ describe('services/jobs/jobExecutionService', () => {
bus.emit('jobs:runAll', { userId: 'u1' });
// allow microtasks to flush
await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1']));
expect(new Set(calls.markRunning)).toEqual(new Set(['j1']));
// Admin: all jobs
calls.markRunning = [];
bus.emit('jobs:runAll', { userId: 'admin' });
await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1', 'j2']));
expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
});
});

View File

@@ -3,18 +3,15 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import esmock from 'esmock';
import { vi, describe, it, expect } from 'vitest';
// Helper to create module under test with mocks
async function loadModuleWith({ entries = [] } = {}) {
const mod = await esmock('../../lib/services/similarity-check/similarityCache.js', {
// Mock the storage to return our controlled entries
'../../lib/services/storage/listingsStorage.js': {
getAllEntriesFromListings: () => entries,
},
});
return mod;
vi.resetModules();
vi.doMock('../../lib/services/storage/listingsStorage.js', () => ({
getAllEntriesFromListings: () => entries,
}));
return await import('../../lib/services/similarity-check/similarityCache.js');
}
describe('similarityCache', () => {
@@ -27,15 +24,15 @@ describe('similarityCache', () => {
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
// Initially, duplicates should not be detected for new data
expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).to.equal(false);
expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).toBe(false);
// Now initialize from storage
initSimilarityCache();
// Exact duplicates should be detected
expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).to.equal(true);
expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).toBe(true);
// Ensure falsy-but-valid price 0 is preserved by hashing and detected as duplicate
expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).to.equal(true);
expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).toBe(true);
});
it('checkAndAddEntry returns false for new entry then true for duplicate on second call', async () => {
@@ -44,8 +41,8 @@ describe('similarityCache', () => {
const first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
expect(first).to.equal(false);
expect(second).to.equal(true);
expect(first).toBe(false);
expect(second).toBe(true);
});
it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
@@ -53,15 +50,15 @@ describe('similarityCache', () => {
// Add baseline (null address ignored)
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
expect(add1).to.equal(false);
expect(add1).toBe(false);
// Duplicate with undefined address should match
const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined });
expect(dup).to.equal(true);
expect(dup).toBe(true);
// Now test that price 0 is preserved (not filtered out)
const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
expect(addZero).to.equal(false);
expect(addZero).toBe(false);
const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
expect(dupZero).to.equal(true);
expect(dupZero).toBe(true);
});
});

View File

@@ -3,8 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import esmock from 'esmock';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
// We explicitly avoid touching the real filesystem or creating a real DB file.
// better-sqlite3 is fully mocked and operates in-memory via our stubs.
@@ -78,15 +77,10 @@ describe('SqliteConnection', () => {
};
};
// esmock the module with our stubs
SqliteConnection = await esmock(
'../../lib/services/storage/SqliteConnection.js',
{},
{
fs: fsMock,
'better-sqlite3': { default: BetterSqlite3Mock },
},
);
vi.resetModules();
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
vi.doMock('better-sqlite3', () => ({ default: BetterSqlite3Mock }));
SqliteConnection = (await import('../../lib/services/storage/SqliteConnection.js')).default;
});
afterEach(() => {
@@ -98,9 +92,9 @@ describe('SqliteConnection', () => {
const db1 = SqliteConnection.getConnection();
const db2 = SqliteConnection.getConnection();
expect(db1).to.equal(db2);
expect(db1).toBe(db2);
// journal_mode, synchronous, cache_size, foreign_keys, optimize
expect(calls.db.pragma).to.deep.equal([
expect(calls.db.pragma).toEqual([
'journal_mode = WAL',
'synchronous = NORMAL',
'cache_size = -64000',
@@ -108,21 +102,21 @@ describe('SqliteConnection', () => {
'optimize',
]);
// mkdirSync should not be called because existsSync returned true
expect(calls.fs.mkdirSync).to.have.length(0);
expect(calls.fs.mkdirSync).toHaveLength(0);
});
it('executes query and execute helpers', () => {
const rows = SqliteConnection.query('SELECT 1', {});
expect(rows).to.be.an('array');
expect(rows[0]).to.deep.equal({ x: 1 });
expect(rows).toBeInstanceOf(Array);
expect(rows[0]).toEqual({ x: 1 });
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
expect(info).to.have.property('changes', 1);
expect(info).toHaveProperty('changes', 1);
});
it('tableExists uses sqlite_master get()', () => {
const exists = SqliteConnection.tableExists('users');
expect(exists).to.equal(true);
expect(exists).toBe(true);
});
it('withTransaction wraps callback', () => {
@@ -131,17 +125,17 @@ describe('SqliteConnection', () => {
db.prepare('SELECT inside').all({});
return 42;
});
expect(result).to.equal(42);
expect(calls.db.prepare).to.include('SELECT inside');
expect(result).toBe(42);
expect(calls.db.prepare).toContain('SELECT inside');
});
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
SqliteConnection.optimize();
// It will use the existing connection and call pragma('optimize')
expect(calls.db.pragma).to.include('optimize');
expect(calls.db.pragma).toContain('optimize');
SqliteConnection.close();
// close increments close counter
expect(calls.db.close).to.equal(1);
expect(calls.db.close).toBe(1);
});
});

View File

@@ -3,29 +3,24 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { vi } from 'vitest';
import { readFile } from 'fs/promises';
import esmock from 'esmock';
import * as mockStore from './mocks/mockStore.js';
import { send } from './mocks/mockNotification.js';
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
vi.mock('../lib/services/storage/listingsStorage.js', () => mockStore);
vi.mock('../lib/services/storage/settingsStorage.js', () => mockStore);
vi.mock('../lib/services/geocoding/geoCodingService.js', () => ({
geocodeAddress: mockStore.getGeocoordinatesByAddress,
}));
vi.mock('../lib/services/storage/jobStorage.js', () => ({
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
}));
vi.mock('../lib/notification/notify.js', () => ({ send }));
export const mockFredy = async () => {
return await esmock('../lib/FredyPipelineExecutioner', {
'../lib/services/storage/listingsStorage.js': {
...mockStore,
},
'../lib/services/storage/settingsStorage.js': {
...mockStore,
},
'../lib/services/geocoding/geoCodingService.js': {
geocodeAddress: mockStore.getGeocoordinatesByAddress,
},
'../lib/services/storage/jobStorage.js': {
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
},
'../lib/notification/notify.js': {
send,
},
});
const mod = await import('../lib/FredyPipelineExecutioner.js');
return mod.default ?? mod;
};

View File

@@ -3,19 +3,19 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import { expect } from 'vitest';
import { buildHash } from '../../lib/utils.js';
describe('utilsCheck', () => {
describe('#utilsCheck()', () => {
it('should be null when null input', () => {
expect(buildHash(null)).to.be.null;
expect(buildHash(null)).toBeNull();
});
it('should be null when null empty', () => {
expect(buildHash('')).to.be.null;
expect(buildHash('')).toBeNull();
});
it('should return a value', () => {
expect(buildHash('bla', '', null)).to.be.a.string;
expect(buildHash('bla', '', null)).toBeTypeOf('string');
});
});
});

View File

@@ -3,36 +3,86 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
import { useState } from 'react';
import { Banner, Button, Modal, Tag, Space, Typography, Descriptions, MarkdownRender } from '@douyinfe/semi-ui-19';
import { IconAlertCircle, IconArrowRight } from '@douyinfe/semi-icons';
import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui-19';
import './VersionBanner.less';
const { Text } = Typography;
export default function VersionBanner() {
const [modalVisible, setModalVisible] = useState(false);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<Collapse>
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
<div className="versionBanner__content">
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
<Descriptions row size="small">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
<Descriptions.Item itemKey="Github Release">
<a href={versionUpdate.url} target="_blank" rel="noreferrer">
{versionUpdate.url}
</a>{' '}
</Descriptions.Item>
</Descriptions>
<p>
<b>
<small>Release Notes</small>
</b>
</p>
<>
<Banner
className="versionBanner"
type="warning"
bordered
closeIcon={null}
description={
<div className="versionBanner__bar">
<Space spacing={8} align="center">
<IconAlertCircle size="small" />
<Text strong size="small">
New version available
</Text>
<Tag color="amber" size="small" shape="circle">
{versionUpdate.version}
</Tag>
<Text type="tertiary" size="small">
Current: {versionUpdate.localFredyVersion}
</Text>
</Space>
<Button
theme="borderless"
size="small"
icon={<IconArrowRight />}
iconPosition="right"
onClick={() => setModalVisible(true)}
>
Release notes
</Button>
</div>
}
/>
<Modal
title={
<Space spacing={8} align="center">
<Text strong>Fredy {versionUpdate.version}</Text>
<Tag color="amber" size="small">
New
</Tag>
</Space>
}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
width={640}
footer={
<Space>
<Button onClick={() => setModalVisible(false)}>Close</Button>
<Button
type="primary"
icon={<IconArrowRight />}
iconPosition="right"
onClick={() => window.open(versionUpdate.url, '_blank')}
>
View on GitHub
</Button>
</Space>
}
>
<Descriptions row size="small" className="versionBanner__details">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
</Descriptions>
<div className="versionBanner__notes">
<MarkdownRender raw={versionUpdate.body} />
</div>
</Collapse.Panel>
</Collapse>
</Modal>
</>
);
}

View File

@@ -1,7 +1,24 @@
.versionBanner {
background: rgba(var(--semi-teal-1), 1);
margin-bottom: 0 !important;
&__content {
overflow: auto;
.semi-banner-body {
padding: 6px 16px;
}
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
&__details {
margin-bottom: 16px;
}
&__notes {
max-height: 400px;
overflow-y: auto;
padding-right: 4px;
}
}

View File

@@ -158,11 +158,11 @@ export default function NotificationAdapterMutator({
{uiElement.type === 'boolean' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Switch
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
{uiElement.label}
</div>
) : (
@@ -173,6 +173,7 @@ export default function NotificationAdapterMutator({
initValue={uiElement.value ?? ''}
placeholder={uiElement.label}
label={uiElement.label}
extraText={uiElement.description}
onChange={(value) => {
setValue(selectedAdapter, uiElement, key, value);
}}

16
vitest.config.js Normal file
View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/**/*.test.js'],
testTimeout: 60000,
reporters: ['verbose'],
},
});

21
vitest.gh.config.js Normal file
View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { defineConfig, mergeConfig } from 'vitest/config';
import base from './vitest.config.js';
export default mergeConfig(
base,
defineConfig({
test: {
exclude: [
'**/node_modules/**',
'test/provider/immonet.test.js',
'test/provider/immobilienDe.test.js',
'test/provider/immowelt.test.js',
],
},
}),
);

1492
yarn.lock

File diff suppressed because it is too large Load Diff