mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: system notifications updates, Thingport grab in System health check, node 24+ (#52)
* feat: update system notifications * feat: add Thingport grab to system health check * ci: upgrade to Node.js 24 and centralize version management
This commit is contained in:
1 parent
0bc0f99482
commit
358dfc1a03
47 files changed
+2534
-2784
No files matched your search
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"rules": {
|
||||
"no-underscore-dangle": ["error", { "allow": ["_max", "_count", "_sum", "_avg", "_min"] }],
|
||||
"no-await-in-loop": "off"
|
||||
"no-await-in-loop": "off",
|
||||
"consistent-function-scoping": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS dependencies
|
||||
FROM node:24-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl
|
||||
COPY package*.json ./
|
||||
@@ -15,7 +15,7 @@ FROM dependencies AS build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
FROM node:24-alpine AS runtime
|
||||
# Set by build-image.yml to the commit that triggered this image's build -- exposed via
|
||||
# GET /settings/version-check so the admin settings page can tell whether a newer commit
|
||||
# touching backend/ has landed on main since this image was published. Left "unknown" for a
|
||||
@@ -31,7 +31,7 @@ COPY --from=build /app/dist ./dist
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
# node:20-alpine already ships a "node" user/group at uid/gid 1000; remove it first so our own
|
||||
# node:24-alpine already ships a "node" user/group at uid/gid 1000; remove it first so our own
|
||||
# "appuser"/"appgroup" (which docker-entrypoint.sh re-aligns to PUID/PGID at container start)
|
||||
# can claim that uid/gid without colliding.
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
|
||||
|
||||
Generated
+511
-474
File diff suppressed because it is too large.
Load diff
+36
-29
@@ -5,7 +5,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "commonjs",
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
@@ -18,47 +18,54 @@
|
||||
"lint": "oxlint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cheerio": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^18.0.8",
|
||||
"express": "^4.22.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.2",
|
||||
"meshoptimizer": "^1.3.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"multer": "^2.0.1",
|
||||
"nodemailer": "^10.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"multer": "^2.4.0",
|
||||
"nodemailer": "^10.1.2",
|
||||
"openid-client": "^6.8.8",
|
||||
"prisma": "^5.20.0",
|
||||
"sharp": "^0.35.4",
|
||||
"prisma": "^5.22.0",
|
||||
"sharp": "^0.35.5",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"three": "^0.160.1",
|
||||
"three": "^0.186.1",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.23.8"
|
||||
"yauzl": "^3.4.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/three": "^0.160.0",
|
||||
"@types/three": "^0.186.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"oxlint": "^1.81.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^4.1.11"
|
||||
"@types/yauzl": "^3.4.0",
|
||||
"oxlint": "^1.88.0",
|
||||
"supertest": "^7.3.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"overrides": {
|
||||
"qs": "^6.16.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma": true,
|
||||
"@prisma/client": true,
|
||||
"@prisma/engines": true,
|
||||
"esbuild": true,
|
||||
"fsevents": false
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import express, { type Express, type NextFunction, type Request, type Response }
|
||||
import cors from "cors";
|
||||
import { resolveCorsOrigins } from "./cors";
|
||||
import { HttpError } from "./utils/fileUtils";
|
||||
import { trackClients } from "./services/clientActivityService";
|
||||
|
||||
import healthRoutes from "./routes/health";
|
||||
import captchaRoutes from "./routes/captcha";
|
||||
@@ -42,6 +43,7 @@ export function createApp(): Express {
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
app.use("/api", trackClients);
|
||||
|
||||
// The frontend's nginx proxies /api/* here unmodified.
|
||||
app.use("/api", healthRoutes);
|
||||
|
||||
@@ -45,9 +45,16 @@ export const TAG_MOSAICS = path.join(STORAGE, "tag-mosaics");
|
||||
export const NORMALIZED_3MFS = path.join(STORAGE, "normalized-3mf");
|
||||
// Large multi-part projects take minutes on a slow CPU; the page waits for it, not the request.
|
||||
export const NORMALIZE_3MF_TIMEOUT_SECONDS = envInt("NORMALIZE_3MF_TIMEOUT_SECONDS", 600);
|
||||
|
||||
// A worker can't even start below a few MB, and Node 24 then takes the whole process down with it.
|
||||
const MIN_PREVIEW_MEMORY_MB = 8;
|
||||
|
||||
// Past either limit the render worker is killed and the plate gets no 3D preview. Memory is a
|
||||
// watchdog check, so a fast burst can overshoot it briefly.
|
||||
export const MODEL_PREVIEW_MAX_MEMORY_MB = envInt("MODEL_PREVIEW_MAX_MEMORY_MB", defaultPreviewMemoryMb());
|
||||
export const MODEL_PREVIEW_MAX_MEMORY_MB = Math.max(
|
||||
MIN_PREVIEW_MEMORY_MB,
|
||||
envInt("MODEL_PREVIEW_MAX_MEMORY_MB", defaultPreviewMemoryMb()),
|
||||
);
|
||||
export const MODEL_PREVIEW_TIMEOUT_SECONDS = envInt("MODEL_PREVIEW_TIMEOUT_SECONDS", 180);
|
||||
|
||||
// Only the first run's value: it's copied into the database, and managed there (Admin > Auth) from then on.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { prisma } from "../db";
|
||||
import { isExtensionRequest } from "./captchaService";
|
||||
|
||||
// When Thingport Grab last reached this instance, for System Health. A web page can't ask whether an extension is
|
||||
// installed, but Grab makes requests of its own here.
|
||||
|
||||
export type Client = "grab";
|
||||
|
||||
const SETTING_KEY: Record<Client, string> = { grab: "client_seen_grab" };
|
||||
// Grab makes requests all day; the last-seen time only needs to be roughly right.
|
||||
const WRITE_EVERY_MS = 10 * 60 * 1000;
|
||||
const lastWritten = new Map<Client, number>();
|
||||
|
||||
function record(client: Client) {
|
||||
const now = Date.now();
|
||||
if (now - (lastWritten.get(client) ?? 0) < WRITE_EVERY_MS) return;
|
||||
lastWritten.set(client, now);
|
||||
const value = { seen_at: new Date(now).toISOString() };
|
||||
// Never holds up or breaks the request it was seen on.
|
||||
void prisma.setting
|
||||
.upsert({ where: { key: SETTING_KEY[client] }, create: { key: SETTING_KEY[client], value }, update: { value } })
|
||||
.catch(() => lastWritten.delete(client));
|
||||
}
|
||||
|
||||
export function trackClients(req: Request, _res: Response, next: NextFunction) {
|
||||
if (isExtensionRequest(req)) record("grab");
|
||||
next();
|
||||
}
|
||||
|
||||
export async function clientLastSeen(client: Client): Promise<Date | null> {
|
||||
const row = await prisma.setting.findUnique({ where: { key: SETTING_KEY[client] } });
|
||||
const seen = (row?.value as { seen_at?: string } | undefined)?.seen_at;
|
||||
return seen ? new Date(seen) : null;
|
||||
}
|
||||
|
||||
/** For tests: forget what was written, so the next request records again. */
|
||||
export function resetClientTracking() {
|
||||
lastWritten.clear();
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SYSTEM_NOTIFICATIONS_URL,
|
||||
} from "../config";
|
||||
import { prisma } from "../db";
|
||||
import { clientLastSeen } from "./clientActivityService";
|
||||
import { listAiProviderModels } from "./aiCategorizationService";
|
||||
import { resolveAiConnection } from "./aiConnectionService";
|
||||
import { testFlaresolverr } from "./flaresolverr";
|
||||
@@ -29,6 +30,7 @@ export const HEALTH_COMPONENTS = [
|
||||
"internet",
|
||||
"flaresolverr",
|
||||
"thingiverse",
|
||||
"grab",
|
||||
"ai",
|
||||
"smtp",
|
||||
] as const;
|
||||
@@ -230,6 +232,18 @@ async function checkSmtp(): Promise<Found> {
|
||||
}
|
||||
}
|
||||
|
||||
// Seen, not tested: it can't be reached from here, so this is the last time it reached Thingport.
|
||||
async function checkGrab(): Promise<Found> {
|
||||
const [seen, connection] = await Promise.all([
|
||||
clientLastSeen("grab"),
|
||||
// Connections made before Grab was tracked still count.
|
||||
prisma.grabConnection.findFirst({ orderBy: { lastUsedAt: { sort: "desc", nulls: "last" } } }),
|
||||
]);
|
||||
const connected = connection ? (connection.lastUsedAt ?? connection.createdAt) : null;
|
||||
const last = seen && connected ? (seen > connected ? seen : connected) : (seen ?? connected);
|
||||
return last ? found("ok", "ok", { last_seen_at: last.toISOString() }) : found("not_configured", "not_seen");
|
||||
}
|
||||
|
||||
const CHECKS: Record<HealthComponent, () => Promise<Found>> = {
|
||||
database: checkDatabase,
|
||||
storage: checkStorage,
|
||||
@@ -237,6 +251,7 @@ const CHECKS: Record<HealthComponent, () => Promise<Found>> = {
|
||||
internet: checkInternet,
|
||||
flaresolverr: checkFlaresolverr,
|
||||
thingiverse: checkThingiverse,
|
||||
grab: checkGrab,
|
||||
ai: checkAi,
|
||||
smtp: checkSmtp,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "../db";
|
||||
import { SYSTEM_NOTIFICATIONS_URL } from "../config";
|
||||
import { UNREAD_RETENTION_DAYS } from "./notificationService";
|
||||
import { createKeyedLock } from "../utils/concurrency";
|
||||
import { getBackendVersion } from "./versionService";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
@@ -19,16 +20,58 @@ const entrySchema = z.object({
|
||||
.url()
|
||||
.refine((value) => value.startsWith("https://"))
|
||||
.nullish(),
|
||||
// An in-app page opened on click, e.g. "/admin"; "//" would leave the app.
|
||||
path: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\/(?!\/)\S*$/)
|
||||
.max(200)
|
||||
.nullish(),
|
||||
published_at: z.coerce.date(),
|
||||
// Admins by default: most announcements are about running the instance.
|
||||
audience: z.enum(["admins", "all"]).default("admins"),
|
||||
// Which app versions get it: "1.0.0", "<1.0.0", "<=1.0.0", ">1.0.0" or ">=1.0.0"; omitted for all.
|
||||
version: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(<=|>=|<|>|=)?\s*\d+\.\d+\.\d+$/)
|
||||
.nullish(),
|
||||
});
|
||||
export type SystemNotificationEntry = z.infer<typeof entrySchema>;
|
||||
|
||||
/** One bad entry is skipped rather than hiding the rest. */
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split(".").map(Number);
|
||||
const pb = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** An entry aimed at certain versions is held back while this instance's version is unknown. */
|
||||
export function matchesVersion(range: string | null | undefined, version: string | null): boolean {
|
||||
if (!range) return true;
|
||||
const own = version?.match(/^\d+\.\d+\.\d+/)?.[0];
|
||||
if (!own) return false;
|
||||
const [, op = "=", target] = range.match(/^(<=|>=|<|>|=)?\s*(.+)$/)!;
|
||||
const diff = compareVersions(own, target);
|
||||
switch (op) {
|
||||
case "<":
|
||||
return diff < 0;
|
||||
case "<=":
|
||||
return diff <= 0;
|
||||
case ">":
|
||||
return diff > 0;
|
||||
case ">=":
|
||||
return diff >= 0;
|
||||
default:
|
||||
return diff === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** One bad entry is skipped rather than hiding the rest. Versions before 1.0.0 read only `notifications`
|
||||
* and ignore `version`, so entries they mustn't get live in `entries`. */
|
||||
export function parseSystemNotifications(data: unknown): SystemNotificationEntry[] {
|
||||
const list = (data as { notifications?: unknown } | null)?.notifications;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const file = data as { notifications?: unknown; entries?: unknown } | null;
|
||||
const list = [file?.notifications, file?.entries].flatMap((l) => (Array.isArray(l) ? l : []));
|
||||
return list.flatMap((raw) => {
|
||||
const parsed = entrySchema.safeParse(raw);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
@@ -51,10 +94,20 @@ const withDeliveryLock = createKeyedLock();
|
||||
|
||||
/** Sends each user every entry meant for them they haven't had yet, as an ordinary notification dated
|
||||
* when it was posted. Future entries wait for their date; ones past the unread retention aren't sent. */
|
||||
export function deliverSystemNotifications(entries: SystemNotificationEntry[], now = new Date()): Promise<number> {
|
||||
export function deliverSystemNotifications(
|
||||
entries: SystemNotificationEntry[],
|
||||
now = new Date(),
|
||||
version = getBackendVersion(),
|
||||
): Promise<number> {
|
||||
return withDeliveryLock("system", async () => {
|
||||
const oldest = now.getTime() - UNREAD_RETENTION_DAYS * DAY_MS;
|
||||
const due = entries.filter((e) => e.published_at.getTime() <= now.getTime() && e.published_at.getTime() >= oldest);
|
||||
// No receipt for a version mismatch, so an entry still reaches an instance that updates into its range.
|
||||
const due = entries.filter(
|
||||
(e) =>
|
||||
e.published_at.getTime() <= now.getTime() &&
|
||||
e.published_at.getTime() >= oldest &&
|
||||
matchesVersion(e.version, version),
|
||||
);
|
||||
if (!due.length) return 0;
|
||||
const [users, receipts] = await Promise.all([
|
||||
prisma.user.findMany({ select: { id: true, role: true } }),
|
||||
@@ -83,6 +136,7 @@ export function deliverSystemNotifications(entries: SystemNotificationEntry[], n
|
||||
title: entry.title,
|
||||
body: entry.body ?? null,
|
||||
externalUrl: entry.url ?? null,
|
||||
internalPath: entry.path ?? null,
|
||||
createdAt: entry.published_at,
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -4,8 +4,8 @@ import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Separate file because limits are read when config.ts loads. 1 MB is below a worker's startup
|
||||
// cost, so every render hits a limit.
|
||||
// Separate file because limits are read when config.ts loads. 1 MB is raised to the 8 MB floor (any lower and
|
||||
// the worker can't start, which crashes the whole process on Node 24); 8 MB is still too little for a render.
|
||||
vi.hoisted(() => {
|
||||
process.env.MODEL_PREVIEW_MAX_MEMORY_MB = "1";
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import { resetClientTracking } from "../src/services/clientActivityService";
|
||||
|
||||
const app = createApp();
|
||||
let admin: string;
|
||||
@@ -36,6 +37,24 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("system health", () => {
|
||||
it("reports Grab as not seen until it makes a request of its own", async () => {
|
||||
await prisma.setting.deleteMany({ where: { key: "client_seen_grab" } });
|
||||
await prisma.grabConnection.deleteMany();
|
||||
resetClientTracking();
|
||||
const test = async () =>
|
||||
resultOf(
|
||||
(await request(app).post("/api/admin/health/test").set(as(admin)).send({ component: "grab" })).body,
|
||||
"grab",
|
||||
)!;
|
||||
expect(await test()).toMatchObject({ status: "not_configured", code: "not_seen" });
|
||||
|
||||
await request(app).get("/api/health").set("X-Thingport-Client", "grab");
|
||||
// Recorded without holding up the request.
|
||||
await vi.waitFor(async () => {
|
||||
expect(await test()).toMatchObject({ status: "ok", facts: { last_seen_at: expect.any(String) } });
|
||||
});
|
||||
});
|
||||
|
||||
it("lists every component by importance, the database first and SMTP, which is optional, last", async () => {
|
||||
const res = await request(app).get("/api/admin/health").set(as(admin));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -46,6 +65,7 @@ describe("system health", () => {
|
||||
"internet",
|
||||
"flaresolverr",
|
||||
"thingiverse",
|
||||
"grab",
|
||||
"ai",
|
||||
"smtp",
|
||||
]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import {
|
||||
deliverSystemNotifications,
|
||||
matchesVersion,
|
||||
parseSystemNotifications,
|
||||
type SystemNotificationEntry,
|
||||
} from "../src/services/systemNotificationService";
|
||||
@@ -51,11 +52,19 @@ describe("system notifications", () => {
|
||||
{ id: "bad-url", title: "x", published_at: "2026-10-01T00:00:00Z", url: "not a url" },
|
||||
{ id: "plain-http", title: "x", published_at: "2026-10-01T00:00:00Z", url: "http://example.com" },
|
||||
{ id: "bad-audience", title: "x", published_at: "2026-10-01T00:00:00Z", audience: "everyone" },
|
||||
{ id: "bad-version", title: "x", published_at: "2026-10-01T00:00:00Z", version: "~1.0" },
|
||||
{ id: "off-site-path", title: "x", published_at: "2026-10-01T00:00:00Z", path: "//evil.example" },
|
||||
{ id: "for-old", title: "x", published_at: "2026-10-01T00:00:00Z", version: "<1.0.0" },
|
||||
],
|
||||
});
|
||||
expect(parsed.map((e) => e.id)).toEqual(["ok"]);
|
||||
expect(parsed.map((e) => e.id)).toEqual(["ok", "for-old"]);
|
||||
expect(parsed[0].audience).toBe("admins");
|
||||
expect(parseSystemNotifications({ nope: true })).toEqual([]);
|
||||
const both = parseSystemNotifications({
|
||||
notifications: [{ id: "legacy", title: "x", published_at: "2026-10-01T00:00:00Z" }],
|
||||
entries: [{ id: "new", title: "x", published_at: "2026-10-01T00:00:00Z", version: "1.0.0" }],
|
||||
});
|
||||
expect(both.map((e) => e.id)).toEqual(["legacy", "new"]);
|
||||
});
|
||||
|
||||
it("sends due entries to admins only, dated when they were posted", async () => {
|
||||
@@ -80,6 +89,28 @@ describe("system notifications", () => {
|
||||
expect((await systemOf(admin.id)).some((n) => n.title === "Title everyone")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches version ranges", () => {
|
||||
expect(matchesVersion(undefined, null)).toBe(true);
|
||||
expect(matchesVersion("<1.0.0", "0.9.12")).toBe(true);
|
||||
expect(matchesVersion("<1.0.0", "1.0.0")).toBe(false);
|
||||
expect(matchesVersion("1.0.0", "1.0.0")).toBe(true);
|
||||
expect(matchesVersion("=1.0.0", "1.0.1")).toBe(false);
|
||||
expect(matchesVersion(">1.0.0", "1.10.0")).toBe(true);
|
||||
expect(matchesVersion(">=1.0.0", "1.0.0")).toBe(true);
|
||||
expect(matchesVersion("<=1.0.0", "1.0.1")).toBe(false);
|
||||
expect(matchesVersion("1.0.0", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("sends a version-targeted entry only once the instance is in range", async () => {
|
||||
const targeted = entry("v1", 1, { version: "1.0.0", path: "/admin" });
|
||||
await deliverSystemNotifications([targeted], new Date(), "0.9.0");
|
||||
expect((await systemOf(admin.id)).some((n) => n.title === "Title v1")).toBe(false);
|
||||
await deliverSystemNotifications([targeted], new Date(), "1.0.0");
|
||||
const sent = (await systemOf(admin.id)).filter((n) => n.title === "Title v1");
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0].internalPath).toBe("/admin");
|
||||
});
|
||||
|
||||
it("never sends the same entry twice, even after it was deleted", async () => {
|
||||
const once = entry("once", 1);
|
||||
await deliverSystemNotifications([once]);
|
||||
|
||||
Reference in new issue
Block a user