mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-05-19 11:45:03 -04:00
648 lines
22 KiB
TypeScript
648 lines
22 KiB
TypeScript
/**
|
|
* Arr cleanup notification - definition + Discord + Ntfy + Webhook + Telegram.
|
|
*/
|
|
|
|
import { assertEquals, assertExists } from '@std/assert';
|
|
import { setup, teardown, test, run } from '$test-harness/runner.ts';
|
|
import { PORTS } from '$test-harness/ports.ts';
|
|
import { createMockServer, type CapturedRequest } from '../harness/mock-server.ts';
|
|
import { DiscordNotifier } from '$notifications/notifiers/discord/DiscordNotifier.ts';
|
|
import { NtfyNotifier } from '$notifications/notifiers/ntfy/NtfyNotifier.ts';
|
|
import { WebhookNotifier } from '$notifications/notifiers/webhook/WebhookNotifier.ts';
|
|
import { TelegramNotifier } from '$notifications/notifiers/telegram/TelegramNotifier.ts';
|
|
import { Colors } from '$notifications/notifiers/discord/embed.ts';
|
|
import { arrCleanup } from '$notifications/definitions/arrCleanup.ts';
|
|
import type { ArrCleanupNotificationParams } from '$notifications/definitions/arrCleanup.ts';
|
|
|
|
const MOCK_PORT = PORTS.notifications.arrCleanup;
|
|
let captured: CapturedRequest[];
|
|
let mockServer: Deno.HttpServer;
|
|
|
|
let REAL_DISCORD: string | undefined;
|
|
let REAL_NTFY_URL: string | undefined;
|
|
let REAL_NTFY_TOPIC: string | undefined;
|
|
let REAL_WEBHOOK_URL: string | undefined;
|
|
let REAL_TELEGRAM_TOKEN: string | undefined;
|
|
let REAL_TELEGRAM_CHAT_ID: string | undefined;
|
|
try {
|
|
const envPath = new URL('../.env', import.meta.url).pathname;
|
|
const content = await Deno.readTextFile(envPath);
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eqIdx = trimmed.indexOf('=');
|
|
if (eqIdx > 0) {
|
|
const key = trimmed.slice(0, eqIdx);
|
|
const value = trimmed.slice(eqIdx + 1);
|
|
if (key === 'TEST_DISCORD_WEBHOOK') REAL_DISCORD = value;
|
|
if (key === 'TEST_NTFY_URL') REAL_NTFY_URL = value;
|
|
if (key === 'TEST_NTFY_TOPIC') REAL_NTFY_TOPIC = value;
|
|
if (key === 'TEST_WEBHOOK_URL') REAL_WEBHOOK_URL = value;
|
|
if (key === 'TEST_TELEGRAM_BOT_TOKEN') REAL_TELEGRAM_TOKEN = value;
|
|
if (key === 'TEST_TELEGRAM_CHAT_ID') REAL_TELEGRAM_CHAT_ID = value;
|
|
}
|
|
}
|
|
} catch {
|
|
/* no .env */
|
|
}
|
|
|
|
setup(() => {
|
|
const mock = createMockServer(MOCK_PORT);
|
|
captured = mock.captured;
|
|
mockServer = mock.server;
|
|
});
|
|
|
|
teardown(async () => {
|
|
await mockServer.shutdown();
|
|
});
|
|
|
|
function getAllEmbeds(): Record<string, unknown>[] {
|
|
const embeds: Record<string, unknown>[] = [];
|
|
for (const req of captured) {
|
|
const body = req.body as { embeds?: Record<string, unknown>[] };
|
|
if (body?.embeds) embeds.push(...body.embeds);
|
|
}
|
|
return embeds;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Test data
|
|
// =========================================================================
|
|
|
|
function makeSuccessParams(
|
|
overrides: Partial<ArrCleanupNotificationParams> = {}
|
|
): ArrCleanupNotificationParams {
|
|
return {
|
|
instanceName: 'Movies',
|
|
instanceType: 'radarr',
|
|
deletedCustomFormats: [
|
|
{ id: 1, name: 'HDR' },
|
|
{ id: 2, name: 'DV' }
|
|
],
|
|
deletedQualityProfiles: [{ id: 10, name: 'Any' }],
|
|
skippedQualityProfiles: [],
|
|
deletedEntities: [
|
|
{ id: 100, title: 'Movie A', externalId: 111 },
|
|
{ id: 101, title: 'Movie B', externalId: 222 },
|
|
{ id: 102, title: 'Movie C', externalId: 333 }
|
|
],
|
|
failedEntities: [],
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function makePartialParams(): ArrCleanupNotificationParams {
|
|
return {
|
|
instanceName: 'TV',
|
|
instanceType: 'sonarr',
|
|
deletedCustomFormats: [{ id: 1, name: 'HDR' }],
|
|
deletedQualityProfiles: [],
|
|
skippedQualityProfiles: [
|
|
{ item: { id: 10, name: 'Any' }, reason: 'Profile is assigned to media' },
|
|
{ item: { id: 11, name: 'Custom' }, reason: 'Profile is assigned to media' }
|
|
],
|
|
deletedEntities: [
|
|
{ id: 100, title: 'Show A', externalId: 111 },
|
|
{ id: 101, title: 'Show B', externalId: 222 }
|
|
],
|
|
failedEntities: [{ entity: { id: 102, title: 'Show C', externalId: 333 }, reason: 'HTTP 500' }]
|
|
};
|
|
}
|
|
|
|
function makeFailedParams(): ArrCleanupNotificationParams {
|
|
return {
|
|
instanceName: 'Movies',
|
|
instanceType: 'radarr',
|
|
deletedCustomFormats: [],
|
|
deletedQualityProfiles: [],
|
|
skippedQualityProfiles: [
|
|
{ item: { id: 10, name: 'Any' }, reason: 'Profile is assigned to media' }
|
|
],
|
|
deletedEntities: [],
|
|
failedEntities: [{ entity: { id: 100, title: 'Movie X', externalId: 111 }, reason: 'HTTP 500' }]
|
|
};
|
|
}
|
|
|
|
function makeHardErrorParams(): ArrCleanupNotificationParams {
|
|
return {
|
|
instanceName: 'Movies',
|
|
instanceType: 'radarr',
|
|
deletedCustomFormats: [],
|
|
deletedQualityProfiles: [],
|
|
skippedQualityProfiles: [],
|
|
deletedEntities: [],
|
|
failedEntities: [],
|
|
error: 'fetch failed: ECONNREFUSED'
|
|
};
|
|
}
|
|
|
|
function makeSkippedOnlyWithDeletionParams(): ArrCleanupNotificationParams {
|
|
return {
|
|
instanceName: 'Movies',
|
|
instanceType: 'radarr',
|
|
deletedCustomFormats: [{ id: 1, name: 'HDR' }],
|
|
deletedQualityProfiles: [],
|
|
skippedQualityProfiles: [
|
|
{ item: { id: 10, name: 'Any' }, reason: 'Profile is assigned to media' }
|
|
],
|
|
deletedEntities: [],
|
|
failedEntities: []
|
|
};
|
|
}
|
|
|
|
// =========================================================================
|
|
// Definition
|
|
// =========================================================================
|
|
|
|
test('type includes status', () => {
|
|
assertEquals(arrCleanup(makeSuccessParams()).type, 'arr.cleanup.success');
|
|
assertEquals(arrCleanup(makePartialParams()).type, 'arr.cleanup.partial');
|
|
assertEquals(arrCleanup(makeFailedParams()).type, 'arr.cleanup.failed');
|
|
assertEquals(arrCleanup(makeHardErrorParams()).type, 'arr.cleanup.failed');
|
|
});
|
|
|
|
test('severity maps correctly', () => {
|
|
assertEquals(arrCleanup(makeSuccessParams()).severity, 'success');
|
|
assertEquals(arrCleanup(makePartialParams()).severity, 'warning');
|
|
assertEquals(arrCleanup(makeFailedParams()).severity, 'error');
|
|
assertEquals(arrCleanup(makeHardErrorParams()).severity, 'error');
|
|
});
|
|
|
|
test('title includes instance name', () => {
|
|
assertEquals(arrCleanup(makeSuccessParams()).title.includes('Movies'), true);
|
|
assertEquals(arrCleanup(makePartialParams()).title.includes('TV'), true);
|
|
});
|
|
|
|
test('title includes instance type', () => {
|
|
assertEquals(arrCleanup(makeSuccessParams()).title.includes('Radarr'), true);
|
|
assertEquals(arrCleanup(makePartialParams()).title.includes('Sonarr'), true);
|
|
});
|
|
|
|
test('title reflects status', () => {
|
|
assertEquals(arrCleanup(makeSuccessParams()).title.includes('Complete'), true);
|
|
assertEquals(arrCleanup(makePartialParams()).title.includes('Partial'), true);
|
|
assertEquals(arrCleanup(makeFailedParams()).title.includes('Failed'), true);
|
|
assertEquals(arrCleanup(makeHardErrorParams()).title.includes('Failed'), true);
|
|
});
|
|
|
|
test('entity label: Radarr -> Movies', () => {
|
|
const notification = arrCleanup(makeSuccessParams());
|
|
const sections = notification.blocks?.filter((b) => b.kind === 'section') ?? [];
|
|
const deleted = sections.find((s) => s.kind === 'section' && s.title === 'Deleted');
|
|
assertExists(deleted);
|
|
if (deleted?.kind === 'section') {
|
|
const moviesGroup = deleted.items?.find((g) => g.label === 'Movies');
|
|
assertExists(moviesGroup);
|
|
}
|
|
});
|
|
|
|
test('deleted section labels are capitalized', () => {
|
|
const notification = arrCleanup(makeSuccessParams());
|
|
const sections = notification.blocks?.filter((b) => b.kind === 'section') ?? [];
|
|
const deleted = sections.find((s) => s.kind === 'section' && s.title === 'Deleted');
|
|
assertExists(deleted);
|
|
if (deleted?.kind === 'section') {
|
|
const labels = deleted.items?.map((g) => g.label) ?? [];
|
|
assertEquals(labels.includes('Profiles'), true);
|
|
assertEquals(labels.includes('Formats'), true);
|
|
assertEquals(labels.includes('Movies'), true);
|
|
}
|
|
});
|
|
|
|
test('entity label: Sonarr -> Series', () => {
|
|
const notification = arrCleanup(makePartialParams());
|
|
const sections = notification.blocks?.filter((b) => b.kind === 'section') ?? [];
|
|
const failedSection = sections.find((s) => s.kind === 'section' && s.title === 'Failed Series');
|
|
assertExists(failedSection);
|
|
});
|
|
|
|
test('skipped QPs flip success to partial even with deletions', () => {
|
|
const notification = arrCleanup(makeSkippedOnlyWithDeletionParams());
|
|
assertEquals(notification.type, 'arr.cleanup.partial');
|
|
assertEquals(notification.severity, 'warning');
|
|
});
|
|
|
|
test('skipped QPs only with no deletions yields failed', () => {
|
|
const params: ArrCleanupNotificationParams = {
|
|
instanceName: 'Movies',
|
|
instanceType: 'radarr',
|
|
deletedCustomFormats: [],
|
|
deletedQualityProfiles: [],
|
|
skippedQualityProfiles: [
|
|
{ item: { id: 10, name: 'Any' }, reason: 'Profile is assigned to media' }
|
|
],
|
|
deletedEntities: [],
|
|
failedEntities: []
|
|
};
|
|
const notification = arrCleanup(params);
|
|
assertEquals(notification.type, 'arr.cleanup.failed');
|
|
assertEquals(notification.severity, 'error');
|
|
});
|
|
|
|
test('hard error variant emits Error section with full error text', () => {
|
|
const notification = arrCleanup(makeHardErrorParams());
|
|
const sections = notification.blocks?.filter((b) => b.kind === 'section') ?? [];
|
|
const errorSection = sections.find((s) => s.kind === 'section' && s.title === 'Error');
|
|
assertExists(errorSection);
|
|
if (errorSection?.kind === 'section') {
|
|
assertEquals(errorSection.content.includes('ECONNREFUSED'), true);
|
|
}
|
|
});
|
|
|
|
test('hard error message is first line of error', () => {
|
|
const params: ArrCleanupNotificationParams = {
|
|
...makeHardErrorParams(),
|
|
error: 'fetch failed: ECONNREFUSED\nstack trace follows\n...'
|
|
};
|
|
const notification = arrCleanup(params);
|
|
assertEquals(notification.message, 'fetch failed: ECONNREFUSED');
|
|
});
|
|
|
|
test('definition emits no field blocks (counts only live in message + sections)', () => {
|
|
for (const params of [makeSuccessParams(), makePartialParams(), makeFailedParams()]) {
|
|
const notification = arrCleanup(params);
|
|
const fields = notification.blocks?.filter((b) => b.kind === 'field') ?? [];
|
|
assertEquals(fields.length, 0);
|
|
}
|
|
});
|
|
|
|
test('hard error single-line message equals error text', () => {
|
|
const notification = arrCleanup(makeHardErrorParams());
|
|
assertEquals(notification.message.includes('ECONNREFUSED'), true);
|
|
});
|
|
|
|
test('success message reports deleted total', () => {
|
|
const notification = arrCleanup(makeSuccessParams());
|
|
// 2 CFs + 1 QP + 3 entities = 6
|
|
assertEquals(notification.message.includes('6'), true);
|
|
});
|
|
|
|
test('partial message reports both counts', () => {
|
|
const notification = arrCleanup(makePartialParams());
|
|
// deleted: 1 CF + 2 entities = 3. non-successes: 2 skipped + 1 failed = 3.
|
|
assertEquals(notification.message.includes('3'), true);
|
|
assertEquals(notification.message.toLowerCase().includes('not deleted'), true);
|
|
});
|
|
|
|
test('failed (soft) message reports non-success count', () => {
|
|
const notification = arrCleanup(makeFailedParams());
|
|
// 1 skipped + 1 failed = 2
|
|
assertEquals(notification.message.includes('2'), true);
|
|
});
|
|
|
|
test('deleted section lists item names', () => {
|
|
const notification = arrCleanup(makeSuccessParams());
|
|
const sections = notification.blocks?.filter((b) => b.kind === 'section') ?? [];
|
|
const deletedSection = sections.find((s) => s.kind === 'section' && s.title === 'Deleted');
|
|
assertExists(deletedSection);
|
|
if (deletedSection?.kind === 'section') {
|
|
assertExists(deletedSection.items);
|
|
const allNames = deletedSection.items!.flatMap((g) => g.items);
|
|
assertEquals(allNames.includes('HDR'), true);
|
|
assertEquals(allNames.includes('Any'), true);
|
|
assertEquals(allNames.includes('Movie A'), true);
|
|
}
|
|
});
|
|
|
|
test('skipped section groups by reason', () => {
|
|
const notification = arrCleanup(makePartialParams());
|
|
const sections = notification.blocks?.filter((b) => b.kind === 'section') ?? [];
|
|
const skippedSection = sections.find(
|
|
(s) => s.kind === 'section' && s.title === 'Skipped Profiles'
|
|
);
|
|
assertExists(skippedSection);
|
|
if (skippedSection?.kind === 'section') {
|
|
assertExists(skippedSection.items);
|
|
const group = skippedSection.items!.find((g) => g.label === 'Profile is assigned to media');
|
|
assertExists(group);
|
|
assertEquals(group!.items.length, 2);
|
|
assertEquals(group!.items.includes('Any'), true);
|
|
assertEquals(group!.items.includes('Custom'), true);
|
|
}
|
|
});
|
|
|
|
test('failed entities section groups by reason', () => {
|
|
const notification = arrCleanup(makePartialParams());
|
|
const sections = notification.blocks?.filter((b) => b.kind === 'section') ?? [];
|
|
const failedSection = sections.find((s) => s.kind === 'section' && s.title === 'Failed Series');
|
|
assertExists(failedSection);
|
|
if (failedSection?.kind === 'section') {
|
|
assertExists(failedSection.items);
|
|
const group = failedSection.items!.find((g) => g.label === 'HTTP 500');
|
|
assertExists(group);
|
|
assertEquals(group!.items.includes('Show C'), true);
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// Discord
|
|
// =========================================================================
|
|
|
|
test('discord: success uses correct color', async () => {
|
|
captured.length = 0;
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`,
|
|
username: 'Profilarr',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
|
|
assertEquals(getAllEmbeds()[0]?.color, Colors.SUCCESS);
|
|
});
|
|
|
|
test('discord: partial uses warning color', async () => {
|
|
captured.length = 0;
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`,
|
|
username: 'Profilarr',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makePartialParams()));
|
|
|
|
assertEquals(getAllEmbeds()[0]?.color, Colors.WARNING);
|
|
});
|
|
|
|
test('discord: failed uses error color', async () => {
|
|
captured.length = 0;
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`,
|
|
username: 'Profilarr',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makeFailedParams()));
|
|
|
|
assertEquals(getAllEmbeds()[0]?.color, Colors.ERROR);
|
|
});
|
|
|
|
test('discord: hard error uses error color', async () => {
|
|
captured.length = 0;
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`,
|
|
username: 'Profilarr',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makeHardErrorParams()));
|
|
|
|
assertEquals(getAllEmbeds()[0]?.color, Colors.ERROR);
|
|
});
|
|
|
|
test('discord: section items render with names', async () => {
|
|
captured.length = 0;
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`,
|
|
username: 'Profilarr',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
|
|
const embeds = getAllEmbeds();
|
|
const allFields = embeds.flatMap((e) => (e.fields as { name: string; value: string }[]) ?? []);
|
|
|
|
// The Deleted SectionBlock renders as a field whose VALUE contains item names.
|
|
// (FieldBlocks with names like "Deleted Formats" just hold counts.)
|
|
const deletedSection = allFields.find(
|
|
(f) => f.value.includes('HDR') && f.value.includes('Movie A')
|
|
);
|
|
assertExists(deletedSection);
|
|
});
|
|
|
|
test('discord: skipped reason labels appear', async () => {
|
|
captured.length = 0;
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`,
|
|
username: 'Profilarr',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makePartialParams()));
|
|
|
|
const embeds = getAllEmbeds();
|
|
const allFields = embeds.flatMap((e) => (e.fields as { name: string; value: string }[]) ?? []);
|
|
const skippedSection = allFields.find((f) => f.value.includes('Profile is assigned to media'));
|
|
assertExists(skippedSection);
|
|
});
|
|
|
|
// =========================================================================
|
|
// Ntfy
|
|
// =========================================================================
|
|
|
|
test('ntfy: success maps to priority 3', async () => {
|
|
captured.length = 0;
|
|
const notifier = new NtfyNotifier({
|
|
server_url: `http://localhost:${MOCK_PORT}`,
|
|
topic: 'test-topic'
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
|
|
const payload = captured[0]?.body as Record<string, unknown>;
|
|
assertEquals(payload?.priority, 3);
|
|
assertEquals(payload?.tags, ['white_check_mark']);
|
|
});
|
|
|
|
test('ntfy: partial maps to priority 4', async () => {
|
|
captured.length = 0;
|
|
const notifier = new NtfyNotifier({
|
|
server_url: `http://localhost:${MOCK_PORT}`,
|
|
topic: 'test-topic'
|
|
});
|
|
await notifier.notify(arrCleanup(makePartialParams()));
|
|
|
|
const payload = captured[0]?.body as Record<string, unknown>;
|
|
assertEquals(payload?.priority, 4);
|
|
assertEquals(payload?.tags, ['warning']);
|
|
});
|
|
|
|
test('ntfy: failed maps to priority 5', async () => {
|
|
captured.length = 0;
|
|
const notifier = new NtfyNotifier({
|
|
server_url: `http://localhost:${MOCK_PORT}`,
|
|
topic: 'test-topic'
|
|
});
|
|
await notifier.notify(arrCleanup(makeFailedParams()));
|
|
|
|
const payload = captured[0]?.body as Record<string, unknown>;
|
|
assertEquals(payload?.priority, 5);
|
|
assertEquals(payload?.tags, ['x']);
|
|
});
|
|
|
|
test('ntfy: message carries summary, omits section item names', async () => {
|
|
captured.length = 0;
|
|
const notifier = new NtfyNotifier({
|
|
server_url: `http://localhost:${MOCK_PORT}`,
|
|
topic: 'test-topic'
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
|
|
const payload = captured[0]?.body as Record<string, unknown>;
|
|
const message = payload?.message as string;
|
|
// Summary message survives
|
|
assertEquals(message.includes('Deleted'), true);
|
|
assertEquals(message.includes('6'), true);
|
|
// Section item names should not appear
|
|
assertEquals(message.includes('HDR'), false);
|
|
assertEquals(message.includes('Movie A'), false);
|
|
});
|
|
|
|
// =========================================================================
|
|
// Webhook
|
|
// =========================================================================
|
|
|
|
test('webhook: sends full notification with blocks and items', async () => {
|
|
captured.length = 0;
|
|
const notifier = new WebhookNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
|
|
const payload = captured[0]?.body as Record<string, unknown>;
|
|
assertEquals(payload?.type, 'arr.cleanup.success');
|
|
assertEquals(payload?.severity, 'success');
|
|
const blocks = payload?.blocks as { kind: string; items?: unknown[] }[];
|
|
assertExists(blocks);
|
|
const withItems = blocks.filter((b) => b.kind === 'section' && b.items);
|
|
// At least the "Deleted" section should have items
|
|
assertEquals(withItems.length >= 1, true);
|
|
});
|
|
|
|
test('webhook: hard error passes through message + Error section', async () => {
|
|
captured.length = 0;
|
|
const notifier = new WebhookNotifier({
|
|
webhook_url: `http://localhost:${MOCK_PORT}/webhook`
|
|
});
|
|
await notifier.notify(arrCleanup(makeHardErrorParams()));
|
|
|
|
const payload = captured[0]?.body as Record<string, unknown>;
|
|
assertEquals(payload?.type, 'arr.cleanup.failed');
|
|
assertEquals((payload?.message as string).includes('ECONNREFUSED'), true);
|
|
const blocks = payload?.blocks as { kind: string; title?: string; content?: string }[];
|
|
const errorBlock = blocks.find((b) => b.kind === 'section' && b.title === 'Error');
|
|
assertExists(errorBlock);
|
|
assertEquals(errorBlock!.content?.includes('ECONNREFUSED'), true);
|
|
});
|
|
|
|
// =========================================================================
|
|
// Telegram
|
|
// =========================================================================
|
|
|
|
const MOCK_TOKEN = 'test-token-123';
|
|
|
|
test('telegram: success has ✅ prefix', async () => {
|
|
captured.length = 0;
|
|
const notifier = new TelegramNotifier({
|
|
bot_token: MOCK_TOKEN,
|
|
chat_id: '123456',
|
|
api_base_url: `http://localhost:${MOCK_PORT}`
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
|
|
const text = (captured[0]?.body as Record<string, unknown>)?.text as string;
|
|
assertEquals(text.startsWith('\u2705'), true);
|
|
});
|
|
|
|
test('telegram: partial has ⚠️ prefix', async () => {
|
|
captured.length = 0;
|
|
const notifier = new TelegramNotifier({
|
|
bot_token: MOCK_TOKEN,
|
|
chat_id: '123456',
|
|
api_base_url: `http://localhost:${MOCK_PORT}`
|
|
});
|
|
await notifier.notify(arrCleanup(makePartialParams()));
|
|
|
|
const text = (captured[0]?.body as Record<string, unknown>)?.text as string;
|
|
assertEquals(text.startsWith('\u26A0\uFE0F'), true);
|
|
});
|
|
|
|
test('telegram: failed has ❌ prefix', async () => {
|
|
captured.length = 0;
|
|
const notifier = new TelegramNotifier({
|
|
bot_token: MOCK_TOKEN,
|
|
chat_id: '123456',
|
|
api_base_url: `http://localhost:${MOCK_PORT}`
|
|
});
|
|
await notifier.notify(arrCleanup(makeFailedParams()));
|
|
|
|
const text = (captured[0]?.body as Record<string, unknown>)?.text as string;
|
|
assertEquals(text.startsWith('\u274C'), true);
|
|
});
|
|
|
|
test('telegram: message includes instance name but omits item names', async () => {
|
|
captured.length = 0;
|
|
const notifier = new TelegramNotifier({
|
|
bot_token: MOCK_TOKEN,
|
|
chat_id: '123456',
|
|
api_base_url: `http://localhost:${MOCK_PORT}`
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
|
|
const text = (captured[0]?.body as Record<string, unknown>)?.text as string;
|
|
assertEquals(text.includes('Movies'), true);
|
|
assertEquals(text.includes('HDR'), false);
|
|
assertEquals(text.includes('Movie A'), false);
|
|
});
|
|
|
|
// =========================================================================
|
|
// Real sends (skipped without .env)
|
|
// =========================================================================
|
|
|
|
test('real: sends success to Discord', async () => {
|
|
if (!REAL_DISCORD) return;
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: REAL_DISCORD,
|
|
username: 'Profilarr Test',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
});
|
|
|
|
test('real: sends partial to Discord', async () => {
|
|
if (!REAL_DISCORD) return;
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: REAL_DISCORD,
|
|
username: 'Profilarr Test',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makePartialParams()));
|
|
});
|
|
|
|
test('real: sends hard error to Discord', async () => {
|
|
if (!REAL_DISCORD) return;
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
const notifier = new DiscordNotifier({
|
|
webhook_url: REAL_DISCORD,
|
|
username: 'Profilarr Test',
|
|
enable_mentions: false
|
|
});
|
|
await notifier.notify(arrCleanup(makeHardErrorParams()));
|
|
});
|
|
|
|
test('real: sends success to ntfy', async () => {
|
|
if (!REAL_NTFY_URL || !REAL_NTFY_TOPIC) return;
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
const notifier = new NtfyNotifier({
|
|
server_url: REAL_NTFY_URL,
|
|
topic: REAL_NTFY_TOPIC
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
});
|
|
|
|
test('real: sends success to webhook', async () => {
|
|
if (!REAL_WEBHOOK_URL) return;
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
const notifier = new WebhookNotifier({
|
|
webhook_url: REAL_WEBHOOK_URL
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
});
|
|
|
|
test('real: sends success to Telegram', async () => {
|
|
if (!REAL_TELEGRAM_TOKEN || !REAL_TELEGRAM_CHAT_ID) return;
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
const notifier = new TelegramNotifier({
|
|
bot_token: REAL_TELEGRAM_TOKEN,
|
|
chat_id: REAL_TELEGRAM_CHAT_ID
|
|
});
|
|
await notifier.notify(arrCleanup(makeSuccessParams()));
|
|
});
|
|
|
|
await run();
|