/**
 * Unit tests for the unified notificationService.send().
 * Covers NSVC-001 to NSVC-014.
 */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';

const { testDb, dbMock } = vi.hoisted(() => {
  const Database = require('better-sqlite3');
  const db = new Database(':memory:');
  db.exec('PRAGMA journal_mode = WAL');
  db.exec('PRAGMA foreign_keys = ON');
  const mock = {
    db,
    closeDb: () => {},
    reinitialize: () => {},
    getPlaceWithTags: () => null,
    canAccessTrip: () => null,
    isOwner: () => false,
  };
  return { testDb: db, dbMock: mock };
});

vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
  JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
  ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
  updateJwtSecret: () => {},
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
  decrypt_api_key: (v: string | null) => v,
  maybe_encrypt_api_key: (v: string) => v,
  encrypt_api_key: (v: string) => v,
}));

const { sendMailMock, fetchMock, broadcastMock } = vi.hoisted(() => ({
  sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }),
  fetchMock: vi.fn(),
  broadcastMock: vi.fn(),
}));

vi.mock('nodemailer', () => ({
  default: {
    createTransport: vi.fn(() => ({
      sendMail: sendMailMock,
      verify: vi.fn().mockResolvedValue(true),
    })),
  },
}));

vi.stubGlobal('fetch', fetchMock);
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
vi.mock('../../../src/utils/ssrfGuard', () => ({
  checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
  createPinnedDispatcher: vi.fn(() => ({})),
}));

import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
import { send } from '../../../src/services/notificationService';

// ── Helpers ────────────────────────────────────────────────────────────────

function setSmtp(): void {
  setAppSetting(testDb, 'smtp_host', 'mail.test.com');
  setAppSetting(testDb, 'smtp_port', '587');
  setAppSetting(testDb, 'smtp_from', 'trek@test.com');
}

function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void {
  testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url);
}

function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void {
  setAppSetting(testDb, 'admin_webhook_url', url);
}

function getInAppNotifications(recipientId: number) {
  return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{
    id: number;
    type: string;
    scope: string;
    navigate_target: string | null;
    navigate_text_key: string | null;
    title_key: string;
    text_key: string;
  }>;
}

function countAllNotifications(): number {
  return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
}

// ── Setup ──────────────────────────────────────────────────────────────────

beforeAll(() => {
  createTables(testDb);
  runMigrations(testDb);
});

beforeEach(() => {
  resetTestDb(testDb);
  sendMailMock.mockClear();
  fetchMock.mockClear();
  broadcastMock.mockClear();
  fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
});

afterAll(() => {
  testDb.close();
  vi.unstubAllGlobals();
});

// ─────────────────────────────────────────────────────────────────────────────
// Multi-channel dispatch
// ─────────────────────────────────────────────────────────────────────────────

describe('send() — multi-channel dispatch', () => {
  it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setUserWebhookUrl(user.id);
    setNotificationChannels(testDb, 'email,webhook');
    testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    expect(sendMailMock).toHaveBeenCalledTimes(1);
    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(broadcastMock).toHaveBeenCalledTimes(1);
    expect(countAllNotifications()).toBe(1);
  });

  it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setUserWebhookUrl(user.id);
    setNotificationChannels(testDb, 'none');

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    expect(sendMailMock).not.toHaveBeenCalled();
    expect(fetchMock).not.toHaveBeenCalled();
    expect(broadcastMock).toHaveBeenCalledTimes(1);
    expect(countAllNotifications()).toBe(1);
  });

  it('NSVC-003 — sends only email when only email channel is active', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setNotificationChannels(testDb, 'email');
    testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number;

    await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });

    expect(sendMailMock).toHaveBeenCalledTimes(1);
    expect(fetchMock).not.toHaveBeenCalled();
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Per-user preference filtering
// ─────────────────────────────────────────────────────────────────────────────

describe('send() — per-user preference filtering', () => {
  it('NSVC-004 — skips email for a user who disabled trip_invite on email channel', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setNotificationChannels(testDb, 'email');
    testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
    disableNotificationPref(testDb, user.id, 'trip_invite', 'email');

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    expect(sendMailMock).not.toHaveBeenCalled();
    // in-app still fires
    expect(broadcastMock).toHaveBeenCalledTimes(1);
  });

  it('NSVC-005 — skips in-app for a user who disabled the event on inapp channel', async () => {
    const { user } = createUser(testDb);
    setNotificationChannels(testDb, 'none');
    disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;

    await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } });

    expect(broadcastMock).not.toHaveBeenCalled();
    expect(countAllNotifications()).toBe(0);
  });

  it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setUserWebhookUrl(user.id);
    setNotificationChannels(testDb, 'email,webhook');
    testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
    disableNotificationPref(testDb, user.id, 'trip_invite', 'email');

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    expect(sendMailMock).not.toHaveBeenCalled();
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Recipient resolution
// ─────────────────────────────────────────────────────────────────────────────

describe('send() — recipient resolution', () => {
  it('NSVC-007 — trip scope sends to owner + members, excludes actorId', async () => {
    const { user: owner } = createUser(testDb);
    const { user: member1 } = createUser(testDb);
    const { user: member2 } = createUser(testDb);
    const { user: actor } = createUser(testDb);
    setNotificationChannels(testDb, 'none');

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
    testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id);
    testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id);
    testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id);

    await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });

    // Owner, member1, member2 get it; actor is excluded
    expect(countAllNotifications()).toBe(3);
    const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
    expect(recipients).toContain(owner.id);
    expect(recipients).toContain(member1.id);
    expect(recipients).toContain(member2.id);
    expect(recipients).not.toContain(actor.id);
  });

  it('NSVC-008 — user scope sends to exactly one user', async () => {
    const { user: target } = createUser(testDb);
    const { user: other } = createUser(testDb);
    setNotificationChannels(testDb, 'none');

    await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } });

    expect(countAllNotifications()).toBe(1);
    const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number };
    expect(notif.recipient_id).toBe(target.id);
  });

  it('NSVC-009 — admin scope sends to all admins (not regular users)', async () => {
    const { user: admin1 } = createAdmin(testDb);
    const { user: admin2 } = createAdmin(testDb);
    createUser(testDb); // regular user — should NOT receive
    setNotificationChannels(testDb, 'none');

    await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });

    expect(countAllNotifications()).toBe(2);
    const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
    expect(recipients).toContain(admin1.id);
    expect(recipients).toContain(admin2.id);
  });

  it('NSVC-010 — admin scope fires admin webhook URL when set', async () => {
    createAdmin(testDb);
    setAdminWebhookUrl();
    setNotificationChannels(testDb, 'none');

    await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });

    // Wait for fire-and-forget admin webhook
    await new Promise(r => setTimeout(r, 10));
    expect(fetchMock).toHaveBeenCalledTimes(1);
    const callUrl = fetchMock.mock.calls[0][0];
    expect(callUrl).toBe('https://hooks.test.com/admin-webhook');
  });

  it('NSVC-011 — does nothing when there are no recipients', async () => {
    // Trip with no members, sending as the trip owner (actor excluded from trip scope)
    const { user: owner } = createUser(testDb);
    setNotificationChannels(testDb, 'none');
    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number;

    await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });

    expect(countAllNotifications()).toBe(0);
    expect(broadcastMock).not.toHaveBeenCalled();
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// In-app notification content
// ─────────────────────────────────────────────────────────────────────────────

describe('send() — in-app notification content', () => {
  it('NSVC-012 — creates navigate in-app notification with correct title/text/navigate keys', async () => {
    const { user } = createUser(testDb);
    setNotificationChannels(testDb, 'none');

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } });

    const notifs = getInAppNotifications(user.id);
    expect(notifs.length).toBe(1);
    expect(notifs[0].type).toBe('navigate');
    expect(notifs[0].title_key).toBe('notif.trip_invite.title');
    expect(notifs[0].text_key).toBe('notif.trip_invite.text');
    expect(notifs[0].navigate_text_key).toBe('notif.action.view_trip');
    expect(notifs[0].navigate_target).toBe('/trips/42');
  });

  it('NSVC-013 — creates simple in-app notification when no navigate target is available', async () => {
    const { user } = createUser(testDb);
    setNotificationChannels(testDb, 'none');

    // vacay_invite without planId → no navigate target → simple type
    await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } });

    const notifs = getInAppNotifications(user.id);
    expect(notifs.length).toBe(1);
    expect(notifs[0].type).toBe('simple');
    expect(notifs[0].navigate_target).toBeNull();
  });

  it('NSVC-014 — navigate_target uses /admin for version_available event', async () => {
    const { user: admin } = createAdmin(testDb);
    setNotificationChannels(testDb, 'none');

    await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });

    const notifs = getInAppNotifications(admin.id);
    expect(notifs.length).toBe(1);
    expect(notifs[0].navigate_target).toBe('/admin');
    expect(notifs[0].title_key).toBe('notif.version_available.title');
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Email/webhook link generation
// ─────────────────────────────────────────────────────────────────────────────

describe('send() — email/webhook links', () => {
  it('NSVC-015 — email subject and body are localized per recipient language', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setNotificationChannels(testDb, 'email');
    testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
    // Set user language to French
    testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id);

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    expect(sendMailMock).toHaveBeenCalledTimes(1);
    const mailArgs = sendMailMock.mock.calls[0][0];
    // French title for trip_invite should contain "Invitation"
    expect(mailArgs.subject).toContain('Invitation');
  });

  it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => {
    const { user } = createUser(testDb);
    setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
    setNotificationChannels(testDb, 'webhook');

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } });

    expect(fetchMock).toHaveBeenCalledTimes(1);
    const body = JSON.parse(fetchMock.mock.calls[0][1].body);
    // Generic webhook — link should contain /trips/55
    expect(body.link).toContain('/trips/55');
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Boolean in-app type
// ─────────────────────────────────────────────────────────────────────────────

describe('send() — boolean in-app type', () => {
  it('NSVC-017 — creates boolean in-app notification with callbacks when inApp.type override is boolean', async () => {
    const { user } = createUser(testDb);
    setNotificationChannels(testDb, 'none');

    await send({
      event: 'trip_invite',
      actorId: null,
      scope: 'user',
      targetId: user.id,
      params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' },
      inApp: {
        type: 'boolean',
        positiveTextKey: 'notif.action.accept',
        negativeTextKey: 'notif.action.decline',
        positiveCallback: { action: 'test_approve', payload: { tripId: 1 } },
        negativeCallback: { action: 'test_deny', payload: { tripId: 1 } },
      },
    });

    const notifs = getInAppNotifications(user.id);
    expect(notifs.length).toBe(1);
    const row = notifs[0] as any;
    expect(row.type).toBe('boolean');
    expect(row.positive_callback).toContain('test_approve');
    expect(row.negative_callback).toContain('test_deny');
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Channel failure resilience
// ─────────────────────────────────────────────────────────────────────────────

describe('send() — channel failure resilience', () => {
  it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setUserWebhookUrl(user.id);
    setNotificationChannels(testDb, 'email,webhook');
    testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);

    // Make email throw
    sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    // In-app and webhook still fire despite email failure
    expect(broadcastMock).toHaveBeenCalledTimes(1);
    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(countAllNotifications()).toBe(1);
  });

  it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => {
    const { user } = createUser(testDb);
    setSmtp();
    setUserWebhookUrl(user.id);
    setNotificationChannels(testDb, 'email,webhook');
    testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);

    // Make webhook throw
    fetchMock.mockRejectedValueOnce(new Error('Network error'));

    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    // In-app and email still fire despite webhook failure
    expect(broadcastMock).toHaveBeenCalledTimes(1);
    expect(sendMailMock).toHaveBeenCalledTimes(1);
    expect(countAllNotifications()).toBe(1);
  });
});

// ── Ntfy dispatch ─────────────────────────────────────────────────────────────

function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void {
  testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic);
}

function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void {
  setAppSetting(testDb, 'admin_ntfy_topic', topic);
}

describe('send() — ntfy channel dispatch', () => {
  beforeEach(() => {
    fetchMock.mockResolvedValue({ ok: true, text: async () => '' });
  });

  it('NTFY-SVCB-001 — ntfy fires when channel active and user has topic configured', async () => {
    const { user } = createUser(testDb);
    setUserNtfyTopic(user.id);
    setNotificationChannels(testDb, 'ntfy');
    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number;

    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });

    const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
    expect(ntfyCalls.length).toBeGreaterThan(0);
    // Header-based API: metadata in headers, body = plain text
    expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // trip_invite = high priority
    expect(ntfyCalls[0][1].headers['Tags']).toContain('loudspeaker');
  });

  it('NTFY-SVCB-002 — ntfy skips when channel not in active channels', async () => {
    const { user } = createUser(testDb);
    setUserNtfyTopic(user.id);
    setNotificationChannels(testDb, 'none');

    fetchMock.mockClear();
    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' } });

    const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
    expect(ntfyCalls.length).toBe(0);
  });

  it('NTFY-SVCB-003 — ntfy skips when user has no topic configured', async () => {
    const { user } = createUser(testDb);
    setNotificationChannels(testDb, 'ntfy');
    // No ntfy_topic set, but no admin_ntfy_server either — resolveNtfyUrl returns null

    fetchMock.mockClear();
    await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });

    const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
    expect(ntfyCalls.length).toBe(0);
  });

  it('NTFY-SVCB-004 — admin-scoped version_available fires admin ntfy topic', async () => {
    createAdmin(testDb);
    setAdminNtfyTopic();
    setNotificationChannels(testDb, 'none');

    fetchMock.mockClear();
    await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } });

    const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
    expect(ntfyCalls.length).toBeGreaterThan(0);
    expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // version_available = high priority
    expect(ntfyCalls[0][1].headers['Tags']).toContain('package');
  });
});
