mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed17f4442 | ||
|
|
b531a7b77a | ||
|
|
3523057221 | ||
|
|
77311cf39d | ||
|
|
556c0aff35 | ||
|
|
c40d275e52 | ||
|
|
cbf2766783 | ||
|
|
1b39e345b6 | ||
|
|
6ccbdd8afc | ||
|
|
2a30c89eb2 | ||
|
|
4878dc98e3 | ||
|
|
dc2704997d | ||
|
|
e107b0fb00 | ||
|
|
6c08675fee | ||
|
|
34c4de7267 | ||
|
|
b64a118a18 | ||
|
|
03cb4d18cb |
93
Dockerfile
93
Dockerfile
@@ -1,70 +1,59 @@
|
|||||||
# ================================
|
FROM node:22-slim
|
||||||
# Stage 1: Build stage
|
|
||||||
# ================================
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
|
||||||
|
# Must run as root
|
||||||
# Install build dependencies needed for native modules (better-sqlite3)
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN apk add --no-cache python3 make g++
|
curl ca-certificates fonts-liberation libasound2 \
|
||||||
|
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
|
||||||
# Copy package files first for better layer caching
|
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
|
||||||
COPY package.json yarn.lock ./
|
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
|
||||||
|
python3 make g++ \
|
||||||
# Install all dependencies (including devDependencies for building)
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
RUN yarn config set network-timeout 600000 \
|
&& mkdir -p /db /conf /fredy \
|
||||||
&& yarn --frozen-lockfile
|
&& chown node:node /db /conf /fredy
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
WORKDIR /fredy
|
WORKDIR /fredy
|
||||||
|
|
||||||
# Install Chromium and curl (for healthcheck)
|
# Everything from here runs as the built-in non-root node user (UID 1000)
|
||||||
# Using Alpine's chromium package which is much smaller
|
USER node
|
||||||
RUN apk add --no-cache chromium curl
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
IS_DOCKER=true \
|
IS_DOCKER=true
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
|
||||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
|
||||||
|
|
||||||
# Install build dependencies for native modules, then remove them after yarn install
|
COPY --chown=node:node package.json yarn.lock ./
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
|
|
||||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
# Install dependencies and purge build tools (only needed to compile better-sqlite3)
|
||||||
&& yarn config set network-timeout 600000 \
|
RUN yarn config set network-timeout 600000 \
|
||||||
&& yarn --frozen-lockfile --production \
|
&& yarn --frozen-lockfile \
|
||||||
&& yarn cache clean \
|
&& yarn cache clean
|
||||||
&& apk del .build-deps
|
|
||||||
|
|
||||||
# Copy built frontend from builder stage
|
# Install Chrome for Testing in a separate layer — it's ~150MB and rarely changes,
|
||||||
COPY --from=builder /build/ui/public ./ui/public
|
# 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)
|
# Purge build tools now that native modules are compiled
|
||||||
COPY index.js ./
|
USER root
|
||||||
COPY index.html ./
|
RUN apt-get purge -y python3 make g++ \
|
||||||
COPY lib ./lib
|
&& apt-get autoremove -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
USER node
|
||||||
|
|
||||||
# Prepare runtime directories and symlinks for data and config
|
COPY --chown=node:node index.html vite.config.js ./
|
||||||
RUN mkdir -p /db /conf \
|
COPY --chown=node:node ui ./ui
|
||||||
&& chown 1000:1000 /db /conf \
|
COPY --chown=node:node lib ./lib
|
||||||
&& chmod 777 /db /conf \
|
|
||||||
&& ln -s /db /fredy/db \
|
RUN yarn build:frontend
|
||||||
|
|
||||||
|
COPY --chown=node:node index.js ./
|
||||||
|
|
||||||
|
RUN ln -s /db /fredy/db \
|
||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
VOLUME /db
|
VOLUME /db
|
||||||
VOLUME /conf
|
VOLUME /conf
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:9998/ || exit 1
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ Jobs run automatically at the interval you configure (see
|
|||||||
Starting with **V20**, Fredy ships with a built-in **MCP Server **. This allows you to connect Fredy to LLMs (like Claude, ChatGPT, or local models via LM Studio) and query your real estate data using natural language.
|
Starting with **V20**, Fredy ships with a built-in **MCP Server **. This allows you to connect Fredy to LLMs (like Claude, ChatGPT, or local models via LM Studio) and query your real estate data using natural language.
|
||||||
The local LLM can even enrich existing listings by checking the listing online.
|
The local LLM can even enrich existing listings by checking the listing online.
|
||||||
|
|
||||||
For more information on how to set it up and use it, please refer to the [MCP Readme](mcp/README.md).
|
For more information on how to set it up and use it, please refer to the [MCP Readme](lib/mcp/README.md).
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,63 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
|
|||||||
docker rm fredy || true
|
docker rm fredy || true
|
||||||
fi
|
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
|
# 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
|
# Run container with volumes and port mapping
|
||||||
docker run -d --name fredy \
|
if [ -n "$PLATFORM" ]; then
|
||||||
-v fredy_conf:/conf \
|
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
-v fredy_db:/db \
|
else
|
||||||
-p 9998:9998 \
|
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
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."
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ export default [
|
|||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
...globals.mocha,
|
...globals.jest,
|
||||||
Promise: 'readonly',
|
Promise: 'readonly',
|
||||||
fetch: 'readonly',
|
fetch: 'readonly',
|
||||||
describe: 'readonly',
|
describe: 'readonly',
|
||||||
after: 'readonly',
|
after: 'readonly',
|
||||||
it: 'readonly',
|
it: 'readonly',
|
||||||
|
beforeEach: 'readonly',
|
||||||
|
afterEach: 'readonly',
|
||||||
|
vi: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: { react },
|
plugins: { react },
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { getSettings } from '../services/storage/settingsStorage.js';
|
|||||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||||
import { backupRouter } from './routes/backupRouter.js';
|
import { backupRouter } from './routes/backupRouter.js';
|
||||||
import { trackingRouter } from './routes/trackingRoute.js';
|
import { trackingRouter } from './routes/trackingRoute.js';
|
||||||
import { registerMcpRoutes } from '../../mcp/mcpHttpRoute.js';
|
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = (await getSettings()).port || 9998;
|
const PORT = (await getSettings()).port || 9998;
|
||||||
|
|||||||
@@ -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:
|
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).
|
> **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)
|
## 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:
|
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
*/
|
*/
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { queryJobs, getJob } from '../lib/services/storage/jobStorage.js';
|
import { queryJobs, getJob } from '../services/storage/jobStorage.js';
|
||||||
import { queryListings, getListingById } from '../lib/services/storage/listingsStorage.js';
|
import { queryListings, getListingById } from '../services/storage/listingsStorage.js';
|
||||||
import { authenticateToolCall, checkJobAccess } from './mcpAuthentication.js';
|
import { authenticateToolCall, checkJobAccess } from './mcpAuthentication.js';
|
||||||
import {
|
import {
|
||||||
normalizeListJobs,
|
normalizeListJobs,
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
* and HTTP requests. Ensures consistent access control across all transports.
|
* and HTTP requests. Ensures consistent access control across all transports.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getUser, validateMcpToken } from '../lib/services/storage/userStorage.js';
|
import { getUser, validateMcpToken } from '../services/storage/userStorage.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate an MCP tool call by extracting and validating the user from authInfo.
|
* Authenticate an MCP tool call by extracting and validating the user from authInfo.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { createMcpServer } from './mcpAdapter.js';
|
import { createMcpServer } from './mcpAdapter.js';
|
||||||
import { authenticateRequest } from './mcpAuthentication.js';
|
import { authenticateRequest } from './mcpAuthentication.js';
|
||||||
import logger from '../lib/services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3,12 +3,10 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#!/usr/bin/env node
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2026 by Christian Kellner.
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fredy MCP Server – stdio transport
|
* Fredy MCP Server – stdio transport
|
||||||
*
|
*
|
||||||
@@ -24,15 +22,15 @@
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import SqliteConnection from '../lib/services/storage/SqliteConnection.js';
|
import SqliteConnection from '../services/storage/SqliteConnection.js';
|
||||||
import { runMigrations } from '../lib/services/storage/migrations/migrate.js';
|
import { runMigrations } from '../services/storage/migrations/migrate.js';
|
||||||
import { createMcpServer } from './mcpAdapter.js';
|
import { createMcpServer } from './mcpAdapter.js';
|
||||||
import { validateMcpToken } from '../lib/services/storage/userStorage.js';
|
import { validateMcpToken } from '../services/storage/userStorage.js';
|
||||||
|
|
||||||
// Ensure cwd is the project root so that relative DB/config paths resolve correctly
|
// Ensure cwd is the project root so that relative DB/config paths resolve correctly
|
||||||
// (LM Studio and other MCP hosts may spawn this process from an arbitrary directory)
|
// (LM Studio and other MCP hosts may spawn this process from an arbitrary directory)
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
process.chdir(path.resolve(__dirname, '..'));
|
process.chdir(path.resolve(__dirname, '..', '..'));
|
||||||
|
|
||||||
// Initialize the database (required for standalone usage)
|
// Initialize the database (required for standalone usage)
|
||||||
await SqliteConnection.init();
|
await SqliteConnection.init();
|
||||||
112
lib/notification/adapter/smtp.js
Normal file
112
lib/notification/adapter/smtp.js
Normal 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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
22
lib/notification/adapter/smtp.md
Normal file
22
lib/notification/adapter/smtp.md
Normal 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
|
||||||
@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
|
|||||||
price: 'price',
|
price: 'price',
|
||||||
constructionyear: 'constructionyear',
|
constructionyear: 'constructionyear',
|
||||||
apartmenttypes: 'apartmenttypes',
|
apartmenttypes: 'apartmenttypes',
|
||||||
|
buildingtypes: 'buildingtypes',
|
||||||
|
ground: 'ground',
|
||||||
pricetype: 'pricetype',
|
pricetype: 'pricetype',
|
||||||
floor: 'floor',
|
floor: 'floor',
|
||||||
geocodes: 'geocodes',
|
geocodes: 'geocodes',
|
||||||
@@ -98,6 +100,7 @@ const EQUIPMENT_MAP = {
|
|||||||
guesttoilet: 'guestToilet',
|
guesttoilet: 'guestToilet',
|
||||||
balcony: 'balcony',
|
balcony: 'balcony',
|
||||||
handicappedaccessible: 'handicappedAccessible',
|
handicappedaccessible: 'handicappedAccessible',
|
||||||
|
lodgerflat: 'lodgerflat',
|
||||||
};
|
};
|
||||||
|
|
||||||
const REAL_ESTATE_TYPE = {
|
const REAL_ESTATE_TYPE = {
|
||||||
@@ -107,6 +110,10 @@ const REAL_ESTATE_TYPE = {
|
|||||||
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
||||||
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
||||||
'haus-kaufen': 'housebuy',
|
'haus-kaufen': 'housebuy',
|
||||||
|
'haus-mit-keller-kaufen': 'housebuy',
|
||||||
|
'luxushaus-kaufen': 'housebuy',
|
||||||
|
'villa-kaufen': 'housebuy',
|
||||||
|
'neubauhaus-kaufen': 'housebuy',
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
||||||
|
|||||||
@@ -29,12 +29,12 @@
|
|||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL, fileURLToPath } from 'url';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import SqliteConnection from '../SqliteConnection.js';
|
import SqliteConnection from '../SqliteConnection.js';
|
||||||
import logger from '../../logger.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).
|
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import crypto from 'crypto';
|
|||||||
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
|
// 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.
|
// Tokens are auto-generated for all existing users during this migration.
|
||||||
export function up(db) {
|
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
|
// 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();
|
const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();
|
||||||
47
package.json
47
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "20.0.0",
|
"version": "20.0.7",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
"build:frontend": "vite build",
|
"build:frontend": "vite build",
|
||||||
"format": "prettier --write \"**/*.js\"",
|
"format": "prettier --write \"**/*.js\"",
|
||||||
"format:check": "prettier --check \"**/*.js\"",
|
"format:check": "prettier --check \"**/*.js\"",
|
||||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
"test": "vitest run",
|
||||||
"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",
|
"testGH": "vitest run --config vitest.gh.config.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"mcp:stdio": "node mcp/stdio.js",
|
"mcp:stdio": "node lib/mcp/stdio.js",
|
||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
||||||
@@ -61,30 +61,31 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.92.2",
|
"@douyinfe/semi-icons": "^2.93.0",
|
||||||
"@douyinfe/semi-ui": "2.92.2",
|
"@douyinfe/semi-ui": "2.93.0",
|
||||||
"@douyinfe/semi-ui-19": "^2.92.2",
|
"@douyinfe/semi-ui-19": "^2.93.0",
|
||||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.0",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.8.0",
|
||||||
"body-parser": "2.2.2",
|
"body-parser": "2.2.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.20.2",
|
||||||
"nanoid": "5.1.6",
|
"nanoid": "5.1.7",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
|
"nodemailer": "^8.0.3",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.38.0",
|
"puppeteer": "^24.40.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
@@ -94,34 +95,32 @@
|
|||||||
"react-range-slider-input": "^3.3.2",
|
"react-range-slider-input": "^3.3.2",
|
||||||
"react-router": "7.13.1",
|
"react-router": "7.13.1",
|
||||||
"react-router-dom": "7.13.1",
|
"react-router-dom": "7.13.1",
|
||||||
"resend": "^6.9.3",
|
"resend": "^6.9.4",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"serve-static": "2.2.1",
|
"serve-static": "2.2.1",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.3.1",
|
"vite": "8.0.1",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.29.0",
|
"@babel/core": "7.29.0",
|
||||||
"@babel/eslint-parser": "7.28.6",
|
"@babel/eslint-parser": "7.28.6",
|
||||||
"@babel/preset-env": "7.29.0",
|
"@babel/preset-env": "7.29.2",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"chai": "6.2.2",
|
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.0.3",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.3",
|
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.5.1",
|
"less": "4.6.4",
|
||||||
"lint-staged": "16.3.2",
|
"lint-staged": "16.4.0",
|
||||||
"mocha": "11.7.5",
|
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"prettier": "3.8.1"
|
"prettier": "3.8.1",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
||||||
let svc;
|
let svc;
|
||||||
@@ -14,7 +13,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
calls = { logger: { info: [], warn: [], error: [] } };
|
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 };
|
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
|
||||||
setZipState = (s) => {
|
setZipState = (s) => {
|
||||||
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...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 utilsMock = { getPackageVersion: async () => '16.2.0' };
|
||||||
|
|
||||||
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
|
vi.resetModules();
|
||||||
const mod = await esmock(
|
vi.doMock('adm-zip', () => admZipMock);
|
||||||
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
|
vi.doMock(migratePath, () => migrateMock);
|
||||||
{},
|
vi.doMock(sqlitePath, () => sqliteMock);
|
||||||
{
|
vi.doMock(loggerPath, () => loggerMock);
|
||||||
'adm-zip': admZipMock,
|
vi.doMock(utilsPath, () => utilsMock);
|
||||||
[admZipPath]: admZipMock,
|
|
||||||
[migratePath]: migrateMock,
|
|
||||||
[sqlitePath]: sqliteMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
[utilsPath]: utilsMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import(path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'));
|
||||||
svc = mod;
|
svc = mod;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: empty upload yields danger', async () => {
|
it('precheck: empty upload yields danger', async () => {
|
||||||
const res = await svc.precheckRestore(Buffer.alloc(0));
|
const res = await svc.precheckRestore(Buffer.alloc(0));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
expect(res.message).to.contain('Empty upload');
|
expect(res.message).toContain('Empty upload');
|
||||||
expect(res.requiredMigration).to.equal(10);
|
expect(res.requiredMigration).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: missing listings.db yields danger', async () => {
|
it('precheck: missing listings.db yields danger', async () => {
|
||||||
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
expect(res.message).to.match(/missing the database file/i);
|
expect(res.message).toMatch(/missing the database file/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: older backup is compatible with warning', async () => {
|
it('precheck: older backup is compatible with warning', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).toBe(true);
|
||||||
expect(res.severity).to.equal('warning');
|
expect(res.severity).toBe('warning');
|
||||||
expect(res.message).to.match(/automatic migrations/i);
|
expect(res.message).toMatch(/automatic migrations/i);
|
||||||
expect(res.backupMigration).to.equal(5);
|
expect(res.backupMigration).toBe(5);
|
||||||
expect(res.requiredMigration).to.equal(10);
|
expect(res.requiredMigration).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: equal backup is compatible with info', async () => {
|
it('precheck: equal backup is compatible with info', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).toBe(true);
|
||||||
expect(res.severity).to.equal('info');
|
expect(res.severity).toBe('info');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: newer backup yields danger', async () => {
|
it('precheck: newer backup yields danger', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildBackupFileName: matches pattern and includes version', async () => {
|
it('buildBackupFileName: matches pattern and includes version', async () => {
|
||||||
const name = await svc.buildBackupFileName();
|
const name = await svc.buildBackupFileName();
|
||||||
expect(name).to.match(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
||||||
expect(name).to.include('16.2.0');
|
expect(name).toContain('16.2.0');
|
||||||
expect(name).to.match(/\.zip$/);
|
expect(name).toMatch(/\.zip$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
|
// 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 path = await import('node:path');
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.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;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
// remember original exitCode to restore later
|
// 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 () => {
|
it('logs and returns when no migration files are found', async () => {
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true);
|
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).toBe(true);
|
||||||
expect(calls.sql.getConnection).to.equal(0);
|
expect(calls.sql.getConnection).toBe(0);
|
||||||
expect(calls.sql.optimize).to.equal(0);
|
expect(calls.sql.optimize).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies a single new migration inside a transaction and records it', async () => {
|
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 path = await import('node:path');
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
|
|
||||||
@@ -178,26 +168,22 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
// Use global importer hook to bypass dynamic import
|
// Use global importer hook to bypass dynamic import
|
||||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../db/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
// Should have started a transaction and inserted into schema_migrations
|
// 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'));
|
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||||
expect(!!inserted).to.equal(true);
|
expect(!!inserted).toBe(true);
|
||||||
expect(calls.sql.optimize).to.equal(1);
|
expect(calls.sql.optimize).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips already executed migration with same checksum', async () => {
|
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: () => {} });
|
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../db/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
// Should not run transaction because it's skipped
|
// Should not run transaction because it's skipped
|
||||||
expect(calls.sql.withTransaction.length).to.equal(0);
|
expect(calls.sql.withTransaction.length).toBe(0);
|
||||||
expect(calls.sql.optimize).to.equal(1);
|
expect(calls.sql.optimize).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
|
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 sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../lib/services/storage/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await 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
|
// 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'));
|
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||||
expect(inserted).to.equal(undefined);
|
expect(inserted).toBe(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { register } from 'node:module';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
|
|
||||||
register('esmock', pathToFileURL('./'));
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { mockFredy } from './utils.js';
|
||||||
import * as mockStore from './mocks/mockStore.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
|
// 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 () => {
|
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
|
// Might throw NoNewListingsWarning if all are filtered out
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(mockStore.deletedIds).to.include('2');
|
expect(mockStore.deletedIds).toContain('2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||||
|
|
||||||
describe('#einsAImmobilien testsuite()', () => {
|
describe('#einsAImmobilien testsuite()', () => {
|
||||||
@@ -23,22 +23,22 @@ describe('#einsAImmobilien testsuite()', () => {
|
|||||||
similarityCache,
|
similarityCache,
|
||||||
);
|
);
|
||||||
fredy.execute().then((listings) => {
|
fredy.execute().then((listings) => {
|
||||||
expect(listings).to.be.a('array');
|
expect(listings).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
expect(notificationObj.serviceName).toBe('einsAImmobilien');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).to.be.not.empty;
|
expect(notify.size).not.toBe('');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de');
|
expect(notify.link).toContain('https://www.1a-immobilienmarkt.de');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||||
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
@@ -16,24 +16,24 @@ describe('#immobilien.de testsuite()', () => {
|
|||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('immobilienDe');
|
expect(notificationObj.serviceName).toBe('immobilienDe');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.immobilien.de');
|
expect(notify.link).toContain('https://www.immobilien.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
@@ -16,22 +16,22 @@ describe('#immoscout provider testsuite()', () => {
|
|||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
|
||||||
fredy.execute().then((listings) => {
|
fredy.execute().then((listings) => {
|
||||||
expect(listings).to.be.a('array');
|
expect(listings).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('immoscout');
|
expect(notificationObj.serviceName).toBe('immoscout');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).to.be.not.empty;
|
expect(notify.size).not.toBe('');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.immobilienscout24.de/');
|
expect(notify.link).toContain('https://www.immobilienscout24.de/');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/immoswp.js';
|
import * as provider from '../../lib/provider/immoswp.js';
|
||||||
|
|
||||||
describe('#immoswp testsuite()', () => {
|
describe('#immoswp testsuite()', () => {
|
||||||
@@ -16,21 +16,21 @@ describe('#immoswp testsuite()', () => {
|
|||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('immoswp');
|
expect(notificationObj.serviceName).toBe('immoswp');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://immo.swp.de');
|
expect(notify.link).toContain('https://immo.swp.de');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/immowelt.js';
|
import * as provider from '../../lib/provider/immowelt.js';
|
||||||
|
|
||||||
describe('#immowelt testsuite()', () => {
|
describe('#immowelt testsuite()', () => {
|
||||||
@@ -17,24 +17,24 @@ describe('#immowelt testsuite()', () => {
|
|||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('immowelt');
|
expect(notificationObj.serviceName).toBe('immowelt');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
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.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.immowelt.de');
|
expect(notify.link).toContain('https://www.immowelt.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||||
|
|
||||||
describe('#kleinanzeigen testsuite()', () => {
|
describe('#kleinanzeigen testsuite()', () => {
|
||||||
@@ -23,20 +23,20 @@ describe('#kleinanzeigen testsuite()', () => {
|
|||||||
similarityCache,
|
similarityCache,
|
||||||
);
|
);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
expect(notificationObj.serviceName).toBe('kleinanzeigen');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
expect(notify.link).toContain('https://www.kleinanzeigen.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/mcMakler.js';
|
import * as provider from '../../lib/provider/mcMakler.js';
|
||||||
|
|
||||||
describe('#mcMakler testsuite()', () => {
|
describe('#mcMakler testsuite()', () => {
|
||||||
@@ -17,22 +17,22 @@ describe('#mcMakler testsuite()', () => {
|
|||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('mcMakler');
|
expect(notificationObj.serviceName).toBe('mcMakler');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||||
|
|
||||||
describe('#neubauKompass testsuite()', () => {
|
describe('#neubauKompass testsuite()', () => {
|
||||||
@@ -23,20 +23,20 @@ describe('#neubauKompass testsuite()', () => {
|
|||||||
similarityCache,
|
similarityCache,
|
||||||
);
|
);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj.serviceName).to.equal('neubauKompass');
|
expect(notificationObj.serviceName).toBe('neubauKompass');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify).to.be.a('object');
|
expect(notify).toBeTypeOf('object');
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.neubaukompass.de');
|
expect(notify.link).toContain('https://www.neubaukompass.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/ohneMakler.js';
|
import * as provider from '../../lib/provider/ohneMakler.js';
|
||||||
|
|
||||||
describe('#ohneMakler testsuite()', () => {
|
describe('#ohneMakler testsuite()', () => {
|
||||||
@@ -17,22 +17,22 @@ describe('#ohneMakler testsuite()', () => {
|
|||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('ohneMakler');
|
expect(notificationObj.serviceName).toBe('ohneMakler');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
||||||
|
|
||||||
describe('#regionalimmobilien24 testsuite()', () => {
|
describe('#regionalimmobilien24 testsuite()', () => {
|
||||||
@@ -24,22 +24,22 @@ describe('#regionalimmobilien24 testsuite()', () => {
|
|||||||
);
|
);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
|
expect(notificationObj.serviceName).toBe('regionalimmobilien24');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/sparkasse.js';
|
import * as provider from '../../lib/provider/sparkasse.js';
|
||||||
|
|
||||||
describe('#sparkasse testsuite()', () => {
|
describe('#sparkasse testsuite()', () => {
|
||||||
@@ -17,22 +17,21 @@ describe('#sparkasse testsuite()', () => {
|
|||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('sparkasse');
|
expect(notificationObj.serviceName).toBe('sparkasse');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"sparkasse": {
|
"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
|
"enabled": true
|
||||||
},
|
},
|
||||||
"wgGesucht": {
|
"wgGesucht": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
|
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
|
|
||||||
const fakeWorkingHoursConfig = (from, to) => ({
|
const fakeWorkingHoursConfig = (from, to) => ({
|
||||||
workingHours: {
|
workingHours: {
|
||||||
@@ -25,19 +25,19 @@ describe('utils', () => {
|
|||||||
});
|
});
|
||||||
describe('#duringWorkingHoursOrNotSet()', () => {
|
describe('#duringWorkingHoursOrNotSet()', () => {
|
||||||
it('should be false', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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)', () => {
|
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
|
||||||
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
||||||
@@ -49,9 +49,9 @@ describe('utils', () => {
|
|||||||
d.setMilliseconds(0);
|
d.setMilliseconds(0);
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
};
|
};
|
||||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).toBe(true); // 23:00 => within window
|
||||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).toBe(false); // 01:00 => outside window
|
||||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).toBe(true); // 06:00 => within window
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||||
|
|
||||||
describe('#wgGesucht testsuite()', () => {
|
describe('#wgGesucht testsuite()', () => {
|
||||||
@@ -16,17 +16,17 @@ describe('#wgGesucht testsuite()', () => {
|
|||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj.serviceName).to.equal('wgGesucht');
|
expect(notificationObj.serviceName).toBe('wgGesucht');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify).to.be.a('object');
|
expect(notify).toBeTypeOf('object');
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.details).to.be.a('string');
|
expect(notify.details).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/wohnungsboerse.js';
|
import * as provider from '../../lib/provider/wohnungsboerse.js';
|
||||||
|
|
||||||
describe('#wohnungsboerse testsuite()', () => {
|
describe('#wohnungsboerse testsuite()', () => {
|
||||||
@@ -23,22 +23,22 @@ describe('#wohnungsboerse testsuite()', () => {
|
|||||||
similarityCache,
|
similarityCache,
|
||||||
);
|
);
|
||||||
fredy.execute().then((listings) => {
|
fredy.execute().then((listings) => {
|
||||||
expect(listings).to.be.a('array');
|
expect(listings).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('wohnungsboerse');
|
expect(notificationObj.serviceName).toBe('wohnungsboerse');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).to.be.not.empty;
|
expect(notify.size).not.toBe('');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.wohnungsboerse.net');
|
expect(notify.link).toContain('https://www.wohnungsboerse.net');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import mutator from '../../lib/services/queryStringMutator.js';
|
import mutator from '../../lib/services/queryStringMutator.js';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
@@ -33,8 +33,8 @@ describe('queryStringMutator', () => {
|
|||||||
const expectedParams = queryString.parseUrl(test.shouldBecome);
|
const expectedParams = queryString.parseUrl(test.shouldBecome);
|
||||||
const actualParams = queryString.parseUrl(fixedUrl);
|
const actualParams = queryString.parseUrl(fixedUrl);
|
||||||
//check if all new params are existing
|
//check if all new params are existing
|
||||||
expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query));
|
expect(Object.keys(expectedParams.query)).toEqual(expect.arrayContaining(Object.keys(actualParams.query)));
|
||||||
expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query));
|
expect(Object.values(expectedParams.query)).toEqual(expect.arrayContaining(Object.values(actualParams.query)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it } from 'mocha';
|
import { expect } from 'vitest';
|
||||||
import { expect } from 'chai';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getPreLaunchConfig,
|
getPreLaunchConfig,
|
||||||
@@ -26,16 +25,16 @@ describe('botPrevention helper', () => {
|
|||||||
};
|
};
|
||||||
const cfg = getPreLaunchConfig(url, options);
|
const cfg = getPreLaunchConfig(url, options);
|
||||||
|
|
||||||
expect(cfg.acceptLanguage).to.equal('de-DE,de;q=0.9');
|
expect(cfg.acceptLanguage).toBe('de-DE,de;q=0.9');
|
||||||
expect(cfg.langArg).to.equal('--lang=de-DE');
|
expect(cfg.langArg).toBe('--lang=de-DE');
|
||||||
expect(cfg.windowSizeArg).to.equal('--window-size=1200,700');
|
expect(cfg.windowSizeArg).toBe('--window-size=1200,700');
|
||||||
expect(cfg.viewport).to.deep.equal({ width: 1200, height: 700, deviceScaleFactor: 2 });
|
expect(cfg.viewport).toEqual({ width: 1200, height: 700, deviceScaleFactor: 2 });
|
||||||
expect(cfg.userAgent).to.equal('TestAgent/1.0');
|
expect(cfg.userAgent).toBe('TestAgent/1.0');
|
||||||
expect(cfg.headers['Accept-Language']).to.equal('de-DE,de;q=0.9');
|
expect(cfg.headers['Accept-Language']).toBe('de-DE,de;q=0.9');
|
||||||
expect(cfg.headers['User-Agent']).to.equal('TestAgent/1.0');
|
expect(cfg.headers['User-Agent']).toBe('TestAgent/1.0');
|
||||||
expect(cfg.headers.Referer).to.equal('https://example.com/ref');
|
expect(cfg.headers.Referer).toBe('https://example.com/ref');
|
||||||
expect(cfg.extraArgs).to.include('--disable-blink-features=AutomationControlled');
|
expect(cfg.extraArgs).toContain('--disable-blink-features=AutomationControlled');
|
||||||
expect(cfg.extraArgs).to.include('--proxy-bypass-list=<-loopback>');
|
expect(cfg.extraArgs).toContain('--proxy-bypass-list=<-loopback>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
|
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
|
||||||
@@ -58,15 +57,15 @@ describe('botPrevention helper', () => {
|
|||||||
|
|
||||||
await applyBotPreventionToPage(page, cfg);
|
await applyBotPreventionToPage(page, cfg);
|
||||||
|
|
||||||
expect(calls[0]).to.deep.equal(['setUserAgent', 'Foo/Bar']);
|
expect(calls[0]).toEqual(['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] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).toBe(true);
|
||||||
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).to.equal(true);
|
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).toBe(true);
|
||||||
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
|
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
|
||||||
expect(headerCall).to.exist;
|
expect(headerCall).toBeDefined();
|
||||||
expect(headerCall[1]['Accept-Language']).to.equal('en-US,en');
|
expect(headerCall[1]['Accept-Language']).toBe('en-US,en');
|
||||||
expect(headerCall[1]['User-Agent']).to.equal('Foo/Bar');
|
expect(headerCall[1]['User-Agent']).toBe('Foo/Bar');
|
||||||
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).to.equal(true);
|
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).toBe(true);
|
||||||
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).to.equal(true);
|
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyLanguagePersistence stores languages early', async () => {
|
it('applyLanguagePersistence stores languages early', async () => {
|
||||||
@@ -80,9 +79,9 @@ describe('botPrevention helper', () => {
|
|||||||
});
|
});
|
||||||
await applyLanguagePersistence(page, cfg);
|
await applyLanguagePersistence(page, cfg);
|
||||||
const call = calls[0];
|
const call = calls[0];
|
||||||
expect(call[0]).to.equal('evaluateOnNewDocument');
|
expect(call[0]).toBe('evaluateOnNewDocument');
|
||||||
expect(call[1]).to.equal('function');
|
expect(call[1]).toBe('function');
|
||||||
expect(call[2]).to.equal('de-DE,de');
|
expect(call[2]).toBe('de-DE,de');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
|
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
|
||||||
@@ -98,7 +97,7 @@ describe('botPrevention helper', () => {
|
|||||||
viewport: { width: 1200, height: 800 },
|
viewport: { width: 1200, height: 800 },
|
||||||
};
|
};
|
||||||
await applyPostNavigationHumanSignals(page, cfg);
|
await applyPostNavigationHumanSignals(page, cfg);
|
||||||
expect(mouseCalls.some((c) => c[0] === 'move')).to.equal(true);
|
expect(mouseCalls.some((c) => c[0] === 'move')).toBe(true);
|
||||||
expect(mouseCalls.some((c) => c[0] === 'wheel')).to.equal(true);
|
expect(mouseCalls.some((c) => c[0] === 'wheel')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
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';
|
'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);
|
const actualMobileUrl = convertWebToMobile(webUrl);
|
||||||
expect(actualMobileUrl).to.equal(expectedMobileUrl);
|
expect(actualMobileUrl).toBe(expectedMobileUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test URL conversion of web-only SEO path
|
// Test URL conversion of web-only SEO path
|
||||||
@@ -27,27 +27,27 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
|
|
||||||
const converted = convertWebToMobile(webUrl);
|
const converted = convertWebToMobile(webUrl);
|
||||||
const queryParams = new URL(converted).searchParams;
|
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
|
// Test URL conversion with unsupported query parameters
|
||||||
it('should remove unsupported query parameters', () => {
|
it('should remove unsupported query parameters', () => {
|
||||||
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
|
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
|
||||||
const converted = convertWebToMobile(webUrl);
|
const converted = convertWebToMobile(webUrl);
|
||||||
expect(converted).that.does.not.include('minimuminternetspeed');
|
expect(converted).not.toContain('minimuminternetspeed');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test URL conversion with invalid URL
|
// Test URL conversion with invalid URL
|
||||||
it('should throw an error for invalid URL', () => {
|
it('should throw an error for invalid URL', () => {
|
||||||
const invalidUrl = '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
|
// Test URL conversion with unexpected path format
|
||||||
it('should throw an error for unexpected path format', () => {
|
it('should throw an error for unexpected path format', () => {
|
||||||
const webUrl = 'https://www.immobilienscout24.de/invalid/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 () => {
|
it('shouldFindResultsForEveryTestData', async () => {
|
||||||
@@ -70,14 +70,12 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
|
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();
|
const responseBody = await response.json();
|
||||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
expect(responseBody.totalResults).toBeGreaterThan(0);
|
||||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
expect(responseBody.totalResults).toBeGreaterThan(0);
|
||||||
expect(responseBody.resultListItems.length).to.greaterThan(0);
|
expect(responseBody.resultListItems.length).toBeGreaterThan(0);
|
||||||
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
|
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).toBe(type);
|
||||||
type,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
describe('services/jobs/jobExecutionService', () => {
|
describe('services/jobs/jobExecutionService', () => {
|
||||||
@@ -22,45 +21,39 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
const brokerPath = root + '/lib/services/sse/sse-broker.js';
|
const brokerPath = root + '/lib/services/sse/sse-broker.js';
|
||||||
const utilsPath = root + '/lib/utils.js';
|
const utilsPath = root + '/lib/utils.js';
|
||||||
const loggerPath = root + '/lib/services/logger.js';
|
const loggerPath = root + '/lib/services/logger.js';
|
||||||
|
const notifyPath = root + '/lib/notification/notify.js';
|
||||||
|
|
||||||
// esmock the service with all its collaborators
|
vi.resetModules();
|
||||||
const mod = await esmock(
|
vi.doMock(busPath, () => ({ bus }));
|
||||||
svcPath,
|
vi.doMock(jobStoragePath, () => ({
|
||||||
{},
|
getJob: (id) => state.jobsById[id] || null,
|
||||||
{
|
getJobs: () => state.jobsList.slice(),
|
||||||
[busPath]: { bus },
|
}));
|
||||||
[jobStoragePath]: {
|
vi.doMock(userStoragePath, () => ({
|
||||||
getJob: (id) => state.jobsById[id] || null,
|
getUsers: () => state.users.slice(),
|
||||||
getJobs: () => state.jobsList.slice(),
|
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
||||||
},
|
}));
|
||||||
[userStoragePath]: {
|
vi.doMock(brokerPath, () => ({
|
||||||
getUsers: () => state.users.slice(),
|
sendToUsers: (...args) => calls.sent.push(args),
|
||||||
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
}));
|
||||||
},
|
vi.doMock(utilsPath, () => ({
|
||||||
[brokerPath]: {
|
duringWorkingHoursOrNotSet: () => false,
|
||||||
sendToUsers: (...args) => calls.sent.push(args),
|
}));
|
||||||
},
|
vi.doMock(loggerPath, () => {
|
||||||
[utilsPath]: {
|
const m = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
||||||
duringWorkingHoursOrNotSet: () => false, // avoid startup run
|
return { default: m };
|
||||||
},
|
});
|
||||||
[loggerPath]: {
|
vi.doMock(notifyPath, () => ({ send: async () => [] }));
|
||||||
debug: () => {},
|
vi.doMock(root + '/lib/services/jobs/run-state.js', () => ({
|
||||||
info: () => {},
|
isRunning: () => false,
|
||||||
warn: () => {},
|
markRunning: (id) => {
|
||||||
error: () => {},
|
calls.markRunning.push(id);
|
||||||
},
|
return true;
|
||||||
[root + '/lib/services/jobs/run-state.js']: {
|
|
||||||
isRunning: () => false,
|
|
||||||
markRunning: (id) => {
|
|
||||||
calls.markRunning.push(id);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
markFinished: () => {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
markFinished: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
// call initializer with minimal deps
|
const mod = await import(svcPath);
|
||||||
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
|
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
|
||||||
return mod;
|
return mod;
|
||||||
}
|
}
|
||||||
@@ -87,13 +80,13 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
|
|
||||||
bus.emit('jobs:status', { jobId: 'j1', running: true });
|
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];
|
const [recipients, event, data] = calls.sent[0];
|
||||||
expect(event).to.equal('jobStatus');
|
expect(event).toBe('jobStatus');
|
||||||
expect(data).to.deep.equal({ jobId: 'j1', running: true });
|
expect(data).toEqual({ jobId: 'j1', running: true });
|
||||||
const got = new Set(recipients);
|
const got = new Set(recipients);
|
||||||
const expected = new Set(['owner1', 'u2', 'a1']);
|
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 () => {
|
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' });
|
bus.emit('jobs:runAll', { userId: 'u1' });
|
||||||
// allow microtasks to flush
|
// allow microtasks to flush
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
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
|
// Admin: all jobs
|
||||||
calls.markRunning = [];
|
calls.markRunning = [];
|
||||||
bus.emit('jobs:runAll', { userId: 'admin' });
|
bus.emit('jobs:runAll', { userId: 'admin' });
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
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']));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,18 +3,15 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
// Helper to create module under test with mocks
|
// Helper to create module under test with mocks
|
||||||
async function loadModuleWith({ entries = [] } = {}) {
|
async function loadModuleWith({ entries = [] } = {}) {
|
||||||
const mod = await esmock('../../lib/services/similarity-check/similarityCache.js', {
|
vi.resetModules();
|
||||||
// Mock the storage to return our controlled entries
|
vi.doMock('../../lib/services/storage/listingsStorage.js', () => ({
|
||||||
'../../lib/services/storage/listingsStorage.js': {
|
getAllEntriesFromListings: () => entries,
|
||||||
getAllEntriesFromListings: () => entries,
|
}));
|
||||||
},
|
return await import('../../lib/services/similarity-check/similarityCache.js');
|
||||||
});
|
|
||||||
return mod;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('similarityCache', () => {
|
describe('similarityCache', () => {
|
||||||
@@ -27,15 +24,15 @@ describe('similarityCache', () => {
|
|||||||
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
|
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
|
||||||
|
|
||||||
// Initially, duplicates should not be detected for new data
|
// 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
|
// Now initialize from storage
|
||||||
initSimilarityCache();
|
initSimilarityCache();
|
||||||
|
|
||||||
// Exact duplicates should be detected
|
// 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
|
// 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 () => {
|
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 first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
||||||
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
||||||
|
|
||||||
expect(first).to.equal(false);
|
expect(first).toBe(false);
|
||||||
expect(second).to.equal(true);
|
expect(second).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
|
it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
|
||||||
@@ -53,15 +50,15 @@ describe('similarityCache', () => {
|
|||||||
|
|
||||||
// Add baseline (null address ignored)
|
// Add baseline (null address ignored)
|
||||||
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
|
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
|
||||||
expect(add1).to.equal(false);
|
expect(add1).toBe(false);
|
||||||
// Duplicate with undefined address should match
|
// Duplicate with undefined address should match
|
||||||
const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined });
|
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)
|
// Now test that price 0 is preserved (not filtered out)
|
||||||
const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
|
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' });
|
const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
|
||||||
expect(dupZero).to.equal(true);
|
expect(dupZero).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
// We explicitly avoid touching the real filesystem or creating a real DB file.
|
// 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.
|
// better-sqlite3 is fully mocked and operates in-memory via our stubs.
|
||||||
@@ -78,15 +77,10 @@ describe('SqliteConnection', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// esmock the module with our stubs
|
vi.resetModules();
|
||||||
SqliteConnection = await esmock(
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
'../../lib/services/storage/SqliteConnection.js',
|
vi.doMock('better-sqlite3', () => ({ default: BetterSqlite3Mock }));
|
||||||
{},
|
SqliteConnection = (await import('../../lib/services/storage/SqliteConnection.js')).default;
|
||||||
{
|
|
||||||
fs: fsMock,
|
|
||||||
'better-sqlite3': { default: BetterSqlite3Mock },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -98,9 +92,9 @@ describe('SqliteConnection', () => {
|
|||||||
const db1 = SqliteConnection.getConnection();
|
const db1 = SqliteConnection.getConnection();
|
||||||
const db2 = SqliteConnection.getConnection();
|
const db2 = SqliteConnection.getConnection();
|
||||||
|
|
||||||
expect(db1).to.equal(db2);
|
expect(db1).toBe(db2);
|
||||||
// journal_mode, synchronous, cache_size, foreign_keys, optimize
|
// journal_mode, synchronous, cache_size, foreign_keys, optimize
|
||||||
expect(calls.db.pragma).to.deep.equal([
|
expect(calls.db.pragma).toEqual([
|
||||||
'journal_mode = WAL',
|
'journal_mode = WAL',
|
||||||
'synchronous = NORMAL',
|
'synchronous = NORMAL',
|
||||||
'cache_size = -64000',
|
'cache_size = -64000',
|
||||||
@@ -108,21 +102,21 @@ describe('SqliteConnection', () => {
|
|||||||
'optimize',
|
'optimize',
|
||||||
]);
|
]);
|
||||||
// mkdirSync should not be called because existsSync returned true
|
// 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', () => {
|
it('executes query and execute helpers', () => {
|
||||||
const rows = SqliteConnection.query('SELECT 1', {});
|
const rows = SqliteConnection.query('SELECT 1', {});
|
||||||
expect(rows).to.be.an('array');
|
expect(rows).toBeInstanceOf(Array);
|
||||||
expect(rows[0]).to.deep.equal({ x: 1 });
|
expect(rows[0]).toEqual({ x: 1 });
|
||||||
|
|
||||||
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
|
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()', () => {
|
it('tableExists uses sqlite_master get()', () => {
|
||||||
const exists = SqliteConnection.tableExists('users');
|
const exists = SqliteConnection.tableExists('users');
|
||||||
expect(exists).to.equal(true);
|
expect(exists).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('withTransaction wraps callback', () => {
|
it('withTransaction wraps callback', () => {
|
||||||
@@ -131,17 +125,17 @@ describe('SqliteConnection', () => {
|
|||||||
db.prepare('SELECT inside').all({});
|
db.prepare('SELECT inside').all({});
|
||||||
return 42;
|
return 42;
|
||||||
});
|
});
|
||||||
expect(result).to.equal(42);
|
expect(result).toBe(42);
|
||||||
expect(calls.db.prepare).to.include('SELECT inside');
|
expect(calls.db.prepare).toContain('SELECT inside');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
|
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
|
||||||
SqliteConnection.optimize();
|
SqliteConnection.optimize();
|
||||||
// It will use the existing connection and call pragma('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();
|
SqliteConnection.close();
|
||||||
// close increments close counter
|
// close increments close counter
|
||||||
expect(calls.db.close).to.equal(1);
|
expect(calls.db.close).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,29 +3,24 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import esmock from 'esmock';
|
|
||||||
import * as mockStore from './mocks/mockStore.js';
|
import * as mockStore from './mocks/mockStore.js';
|
||||||
import { send } from './mocks/mockNotification.js';
|
import { send } from './mocks/mockNotification.js';
|
||||||
|
|
||||||
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
|
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 () => {
|
export const mockFredy = async () => {
|
||||||
return await esmock('../lib/FredyPipelineExecutioner', {
|
const mod = await import('../lib/FredyPipelineExecutioner.js');
|
||||||
'../lib/services/storage/listingsStorage.js': {
|
return mod.default ?? mod;
|
||||||
...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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,19 +3,19 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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';
|
import { buildHash } from '../../lib/utils.js';
|
||||||
|
|
||||||
describe('utilsCheck', () => {
|
describe('utilsCheck', () => {
|
||||||
describe('#utilsCheck()', () => {
|
describe('#utilsCheck()', () => {
|
||||||
it('should be null when null input', () => {
|
it('should be null when null input', () => {
|
||||||
expect(buildHash(null)).to.be.null;
|
expect(buildHash(null)).toBeNull();
|
||||||
});
|
});
|
||||||
it('should be null when null empty', () => {
|
it('should be null when null empty', () => {
|
||||||
expect(buildHash('')).to.be.null;
|
expect(buildHash('')).toBeNull();
|
||||||
});
|
});
|
||||||
it('should return a value', () => {
|
it('should return a value', () => {
|
||||||
expect(buildHash('bla', '', null)).to.be.a.string;
|
expect(buildHash('bla', '', null)).toBeTypeOf('string');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,36 +3,86 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { useSelector } from '../../services/state/store.js';
|
||||||
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
|
||||||
|
|
||||||
import './VersionBanner.less';
|
import './VersionBanner.less';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function VersionBanner() {
|
export default function VersionBanner() {
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse>
|
<>
|
||||||
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
|
<Banner
|
||||||
<div className="versionBanner__content">
|
className="versionBanner"
|
||||||
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
|
type="warning"
|
||||||
<Descriptions row size="small">
|
bordered
|
||||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
closeIcon={null}
|
||||||
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
description={
|
||||||
<Descriptions.Item itemKey="Github Release">
|
<div className="versionBanner__bar">
|
||||||
<a href={versionUpdate.url} target="_blank" rel="noreferrer">
|
<Space spacing={8} align="center">
|
||||||
{versionUpdate.url}
|
<IconAlertCircle size="small" />
|
||||||
</a>{' '}
|
<Text strong size="small">
|
||||||
</Descriptions.Item>
|
New version available
|
||||||
</Descriptions>
|
</Text>
|
||||||
<p>
|
<Tag color="amber" size="small" shape="circle">
|
||||||
<b>
|
{versionUpdate.version}
|
||||||
<small>Release Notes</small>
|
</Tag>
|
||||||
</b>
|
<Text type="tertiary" size="small">
|
||||||
</p>
|
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} />
|
<MarkdownRender raw={versionUpdate.body} />
|
||||||
</div>
|
</div>
|
||||||
</Collapse.Panel>
|
</Modal>
|
||||||
</Collapse>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
.versionBanner {
|
.versionBanner {
|
||||||
background: rgba(var(--semi-teal-1), 1);
|
margin-bottom: 0 !important;
|
||||||
|
|
||||||
&__content {
|
.semi-banner-body {
|
||||||
overflow: auto;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,11 +158,11 @@ export default function NotificationAdapterMutator({
|
|||||||
{uiElement.type === 'boolean' ? (
|
{uiElement.type === 'boolean' ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={uiElement.value || false}
|
checked={uiElement.value || false}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
setValue(selectedAdapter, uiElement, key, checked);
|
setValue(selectedAdapter, uiElement, key, checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{uiElement.label}
|
{uiElement.label}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -173,6 +173,7 @@ export default function NotificationAdapterMutator({
|
|||||||
initValue={uiElement.value ?? ''}
|
initValue={uiElement.value ?? ''}
|
||||||
placeholder={uiElement.label}
|
placeholder={uiElement.label}
|
||||||
label={uiElement.label}
|
label={uiElement.label}
|
||||||
|
extraText={uiElement.description}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setValue(selectedAdapter, uiElement, key, value);
|
setValue(selectedAdapter, uiElement, key, value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
16
vitest.config.js
Normal file
16
vitest.config.js
Normal 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
21
vitest.gh.config.js
Normal 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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user