/**
 * Integration tests for Alert CRUD endpoints, notification test dispatch
 * validation, and auth enforcement on all alert/notification routes.
 */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { PAYLOAD_TEMPLATE_MAX_LENGTH } from '../helpers/notificationPayloadTemplate';

let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS;
let authCookie: string;
let viewerCookie: string;

type SeedRole = 'admin' | 'node-admin' | 'deployer' | 'viewer' | 'auditor';

/** Seed a user with the given role and return a signed bearer token for it. */
async function seedRoleToken(username: string, role: SeedRole): Promise<string> {
  const db = DatabaseService.getInstance();
  let user = db.getUserByUsername(username);
  if (!user) {
    const hash = await bcrypt.hash('password123', 1);
    db.addUser({ username, password_hash: hash, role });
    user = db.getUserByUsername(username);
  }
  return jwt.sign({ username, role, tv: user!.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' });
}

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ DatabaseService } = await import('../services/DatabaseService'));
  ({ ROLE_PERMISSIONS } = await import('../middleware/permissions'));

  // Mock LicenseService so paid-gated routes are accessible
  const { LicenseService } = await import('../services/LicenseService');
  vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');

  ({ app } = await import('../index'));
  authCookie = await loginAsTestAdmin(app);

  // Create a viewer user for non-admin tests
  const viewerHash = await bcrypt.hash('viewerpass', 1);
  DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' });
  const viewerRes = await request(app)
    .post('/api/auth/login')
    .send({ username: 'viewer', password: 'viewerpass' });
  const cookies = viewerRes.headers['set-cookie'] as string | string[];
  viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});

afterAll(() => {
  cleanupTestDb(tmpDir);
});

// --- GET /api/alerts ---

describe('GET /api/alerts', () => {
  it('rejects unauthenticated requests with 401', async () => {
    const res = await request(app).get('/api/alerts');
    expect(res.status).toBe(401);
  });

  it('returns empty array when no alerts exist', async () => {
    const res = await request(app)
      .get('/api/alerts')
      .set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body).toEqual([]);
  });

  it('filters alerts by stackName query param', async () => {
    // Seed two alerts for different stacks
    const db = DatabaseService.getInstance();
    db.addStackAlert({ stack_name: 'web', service_name: null, metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });
    db.addStackAlert({ stack_name: 'api', service_name: null, metric: 'memory_percent', operator: '>', threshold: 90, duration_mins: 5, cooldown_mins: 60 });

    const res = await request(app)
      .get('/api/alerts?stackName=web')
      .set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body.length).toBe(1);
    expect(res.body[0].stack_name).toBe('web');
  });

  it('denies a role without stack:read with 403 PERMISSION_DENIED', async () => {
    // Every shipped role carries stack:read, so the denial path is exercised
    // by temporarily removing it from viewer at runtime, proving the added
    // gate actually runs rather than being a no-op.
    const original = ROLE_PERMISSIONS.viewer;
    ROLE_PERMISSIONS.viewer = original.filter((p) => p !== 'stack:read');
    try {
      const res = await request(app)
        .get('/api/alerts')
        .set('Cookie', viewerCookie);
      expect(res.status).toBe(403);
      expect(res.body.code).toBe('PERMISSION_DENIED');
    } finally {
      ROLE_PERMISSIONS.viewer = original;
    }
  });
});

// --- POST /api/alerts ---

describe('POST /api/alerts', () => {
  it('rejects unauthenticated requests with 401', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .send({ stack_name: 'test', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });
    expect(res.status).toBe(401);
  });

  it.each(['viewer', 'deployer', 'auditor'] as const)(
    'rejects %s with 403 PERMISSION_DENIED (lacks stack:edit)',
    async (role) => {
      const token = await seedRoleToken(`alerts-post-${role}`, role);
      const res = await request(app)
        .post('/api/alerts')
        .set('Authorization', `Bearer ${token}`)
        .send({ stack_name: 'perm-gate-post', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });
      expect(res.status).toBe(403);
      expect(res.body.code).toBe('PERMISSION_DENIED');
    },
  );

  it.each(['admin', 'node-admin'] as const)(
    'lets %s pass the permission gate',
    async (role) => {
      const token = await seedRoleToken(`alerts-post-${role}`, role);
      const res = await request(app)
        .post('/api/alerts')
        .set('Authorization', `Bearer ${token}`)
        .send({ stack_name: `perm-gate-post-${role}`, metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });
      expect(res.status).toBe(201);
    },
  );

  it('creates alert and returns 201 with created resource', async () => {
    const payload = {
      stack_name: 'new-stack',
      metric: 'memory_percent',
      operator: '>=',
      threshold: 85,
      duration_mins: 10,
      cooldown_mins: 30,
    };

    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send(payload);

    expect(res.status).toBe(201);
    expect(res.body.id).toBeDefined();
    expect(res.body.stack_name).toBe('new-stack');
    expect(res.body.service_name).toBeNull();
    expect(res.body.metric).toBe('memory_percent');
    expect(res.body.threshold).toBe(85);
  });

  it('persists a valid dotted service_name', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({
        stack_name: 'svc-stack',
        service_name: 'api.web',
        metric: 'cpu_percent',
        operator: '>',
        threshold: 80,
        duration_mins: 5,
        cooldown_mins: 60,
      });
    expect(res.status).toBe(201);
    expect(res.body.service_name).toBe('api.web');
  });

  it('normalizes empty service_name to null', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({
        stack_name: 'empty-svc',
        service_name: '',
        metric: 'cpu_percent',
        operator: '>',
        threshold: 80,
        duration_mins: 5,
        cooldown_mins: 60,
      });
    expect(res.status).toBe(201);
    expect(res.body.service_name).toBeNull();
  });

  it('rejects whitespace-only service_name', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({
        stack_name: 'bad-svc',
        service_name: '   ',
        metric: 'cpu_percent',
        operator: '>',
        threshold: 80,
        duration_mins: 5,
        cooldown_mins: 60,
      });
    expect(res.status).toBe(400);
  });

  it('rejects reserved _unlabeled service_name', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({
        stack_name: 'bad-svc',
        service_name: '_unlabeled',
        metric: 'cpu_percent',
        operator: '>',
        threshold: 80,
        duration_mins: 5,
        cooldown_mins: 60,
      });
    expect(res.status).toBe(400);
  });

  it('rejects service_name when the capability is disabled', async () => {
    const { disableCapability, enableCapability, SERVICE_SCOPED_STACK_ALERT_CAPABILITY } =
      await import('../services/CapabilityRegistry');
    disableCapability(SERVICE_SCOPED_STACK_ALERT_CAPABILITY);
    try {
      const res = await request(app)
        .post('/api/alerts')
        .set('Cookie', authCookie)
        .send({
          stack_name: 'cap-stack',
          service_name: 'api',
          metric: 'cpu_percent',
          operator: '>',
          threshold: 80,
          duration_mins: 5,
          cooldown_mins: 60,
        });
      expect(res.status).toBe(400);
      expect(res.body.code).toBe('capability_unavailable');
    } finally {
      enableCapability(SERVICE_SCOPED_STACK_ALERT_CAPABILITY);
    }
  });

  it('validates required fields and returns 400 for missing data', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: 'test' }); // missing metric, operator, threshold, etc.

    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Invalid alert data');
  });

  it('rejects invalid metric values', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: 'test', metric: 'invalid_metric', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });

    expect(res.status).toBe(400);
  });

  it('rejects negative threshold', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: 'test', metric: 'cpu_percent', operator: '>', threshold: -1, duration_mins: 5, cooldown_mins: 60 });

    expect(res.status).toBe(400);
  });

  it('rejects empty stack_name', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: '', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });

    expect(res.status).toBe(400);
  });

  it('rejects stack_name exceeding 255 characters', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: 'a'.repeat(256), metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 });

    expect(res.status).toBe(400);
  });

  it('rejects duration_mins exceeding 1440', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: 'test', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 1441, cooldown_mins: 60 });

    expect(res.status).toBe(400);
  });

  it('rejects cooldown_mins exceeding 10080', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: 'test', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 10081 });

    expect(res.status).toBe(400);
  });

  it('rejects invalid operator', async () => {
    const res = await request(app)
      .post('/api/alerts')
      .set('Cookie', authCookie)
      .send({ stack_name: 'test', metric: 'cpu_percent', operator: '!=', threshold: 80, duration_mins: 5, cooldown_mins: 60 });

    expect(res.status).toBe(400);
  });
});

// --- DELETE /api/alerts/:id ---

describe('DELETE /api/alerts/:id', () => {
  it('rejects unauthenticated requests with 401', async () => {
    const res = await request(app).delete('/api/alerts/1');
    expect(res.status).toBe(401);
  });

  it.each(['viewer', 'deployer', 'auditor'] as const)(
    'rejects %s with 403 PERMISSION_DENIED (lacks stack:edit)',
    async (role) => {
      const alert = DatabaseService.getInstance().addStackAlert({
        stack_name: `delete-gate-deny-${role}`,
        service_name: null,
        metric: 'cpu_percent',
        operator: '>',
        threshold: 90,
        duration_mins: 0,
        cooldown_mins: 0,
      });
      const token = await seedRoleToken(`alerts-delete-${role}`, role);
      const res = await request(app)
        .delete(`/api/alerts/${alert.id}`)
        .set('Authorization', `Bearer ${token}`);
      expect(res.status).toBe(403);
      expect(res.body.code).toBe('PERMISSION_DENIED');
    },
  );

  it.each(['admin', 'node-admin'] as const)(
    'lets %s delete',
    async (role) => {
      const alert = DatabaseService.getInstance().addStackAlert({
        stack_name: `delete-gate-allow-${role}`,
        service_name: null,
        metric: 'cpu_percent',
        operator: '>',
        threshold: 90,
        duration_mins: 0,
        cooldown_mins: 0,
      });
      const token = await seedRoleToken(`alerts-delete-${role}`, role);
      const res = await request(app)
        .delete(`/api/alerts/${alert.id}`)
        .set('Authorization', `Bearer ${token}`);
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
    },
  );

  it('returns 404 for a nonexistent alert id', async () => {
    const res = await request(app)
      .delete('/api/alerts/99999')
      .set('Cookie', authCookie);
    expect(res.status).toBe(404);
    expect(res.body).toEqual({ error: 'Alert not found' });
  });

  it("authorizes against the alert's own stack, not a caller's scoped grant on a different stack", async () => {
    const db = DatabaseService.getInstance();
    const defaultNodeId = db.getDefaultNode()!.id!;
    const hash = await bcrypt.hash('password123', 1);
    const userId = db.addUser({ username: 'alerts-scoped-editor', password_hash: hash, role: 'viewer' });
    db.addRoleAssignment({
      user_id: userId,
      role: 'node-admin',
      resource_type: 'stack',
      resource_id: 'scoped-allowed-stack',
      node_id: defaultNodeId,
    });
    const user = db.getUserByUsername('alerts-scoped-editor')!;
    const token = jwt.sign({ username: user.username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' });

    try {
      // The scoped grant only covers 'scoped-allowed-stack', so an alert
      // belonging to a different stack must still be denied.
      const deniedAlert = db.addStackAlert({
        stack_name: 'scoped-other-stack',
        service_name: null,
        metric: 'cpu_percent',
        operator: '>',
        threshold: 90,
        duration_mins: 0,
        cooldown_mins: 0,
      });
      const deniedRes = await request(app)
        .delete(`/api/alerts/${deniedAlert.id}`)
        .set('Authorization', `Bearer ${token}`);
      expect(deniedRes.status).toBe(403);
      expect(deniedRes.body.code).toBe('PERMISSION_DENIED');

      const allowedAlert = db.addStackAlert({
        stack_name: 'scoped-allowed-stack',
        service_name: null,
        metric: 'cpu_percent',
        operator: '>',
        threshold: 90,
        duration_mins: 0,
        cooldown_mins: 0,
      });
      const allowedRes = await request(app)
        .delete(`/api/alerts/${allowedAlert.id}`)
        .set('Authorization', `Bearer ${token}`);
      expect(allowedRes.status).toBe(200);
      expect(allowedRes.body.success).toBe(true);
    } finally {
      db.deleteRoleAssignmentsByUser(userId);
      db.deleteUser(userId);
    }
  });

  it('deletes existing alert rule', async () => {
    // Create an alert to delete
    const created = DatabaseService.getInstance().addStackAlert({
      stack_name: 'delete-me',
      service_name: null,
      metric: 'cpu_percent',
      operator: '>',
      threshold: 90,
      duration_mins: 0,
      cooldown_mins: 0,
    });

    const res = await request(app)
      .delete(`/api/alerts/${created.id}`)
      .set('Cookie', authCookie);

    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
  });

  it('rejects leading-junk ids like 1abc without deleting alert 1', async () => {
    const created = DatabaseService.getInstance().addStackAlert({
      stack_name: 'strict-id-junk',
      service_name: null,
      metric: 'cpu_percent',
      operator: '>',
      threshold: 90,
      duration_mins: 0,
      cooldown_mins: 0,
    });
    expect(created.id).toBeDefined();

    const res = await request(app)
      .delete(`/api/alerts/${created.id}abc`)
      .set('Cookie', authCookie);

    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Invalid alert id');
    expect(DatabaseService.getInstance().getStackAlerts('strict-id-junk').some((a) => a.id === created.id)).toBe(true);
  });

  it('rejects fractional ids like 2.5 without deleting alert 2', async () => {
    const first = DatabaseService.getInstance().addStackAlert({
      stack_name: 'strict-id-fraction',
      service_name: null,
      metric: 'cpu_percent',
      operator: '>',
      threshold: 80,
      duration_mins: 0,
      cooldown_mins: 0,
    });
    const second = DatabaseService.getInstance().addStackAlert({
      stack_name: 'strict-id-fraction',
      service_name: null,
      metric: 'memory_percent',
      operator: '>',
      threshold: 80,
      duration_mins: 0,
      cooldown_mins: 0,
    });
    expect(second.id).toBeDefined();

    const res = await request(app)
      .delete(`/api/alerts/${second.id}.5`)
      .set('Cookie', authCookie);

    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Invalid alert id');
    const remaining = DatabaseService.getInstance().getStackAlerts('strict-id-fraction').map((a) => a.id);
    expect(remaining).toEqual(expect.arrayContaining([first.id, second.id]));
  });
});

// --- POST /api/notifications/test ---

describe('POST /api/notifications/test', () => {
  it('rejects unauthenticated requests with 401', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .send({ type: 'discord', url: 'https://discord.com/api/webhooks/123/abc' });
    expect(res.status).toBe(401);
  });

  it('rejects non-admin users with 403', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', viewerCookie)
      .send({ type: 'discord', url: 'https://discord.com/api/webhooks/123/abc' });
    expect(res.status).toBe(403);
  });

  it('rejects invalid type with 400', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', authCookie)
      .send({ type: 'telegram', url: 'https://example.com' });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('discord, slack, webhook, apprise, ntfy');
  });

  it('rejects missing type with 400', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', authCookie)
      .send({ url: 'https://example.com' });
    expect(res.status).toBe(400);
  });

  it('rejects non-HTTPS url with 400', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', authCookie)
      .send({ type: 'discord', url: 'http://example.com' });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('HTTPS');
  });

  it('rejects missing url with 400', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', authCookie)
      .send({ type: 'discord' });
    expect(res.status).toBe(400);
  });

  it('rejects malformed url with 400', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', authCookie)
      .send({ type: 'discord', url: 'https://' });
    expect(res.status).toBe(400);
  });

  it('dispatches keyed Apprise test with exact payload', async () => {
    const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
    vi.stubGlobal('fetch', fetchMock);
    try {
      const res = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({ type: 'apprise', url: 'http://apprise.local/notify/test-key', config: { tags: 'ops' } });
      expect(res.status).toBe(200);
      expect(fetchMock).toHaveBeenCalledWith(
        'http://apprise.local/notify/test-key',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({
            title: 'Sencho Alert [INFO]',
            body: '🔌 Test Notification from Sencho!',
            type: 'info',
            tag: 'ops',
          }),
        }),
      );
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('dispatches stateless Apprise test with urls and rejects scheme-less tokens', async () => {
    const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
    vi.stubGlobal('fetch', fetchMock);
    try {
      const bad = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'no-scheme' } });
      expect(bad.status).toBe(400);
      expect(fetchMock).not.toHaveBeenCalled();

      const ok = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'discord://token' } });
      expect(ok.status).toBe(200);
      expect(fetchMock).toHaveBeenCalledWith(
        'http://apprise.local/notify',
        expect.objectContaining({
          body: JSON.stringify({
            title: 'Sencho Alert [INFO]',
            body: '🔌 Test Notification from Sencho!',
            type: 'info',
            urls: 'discord://token',
          }),
        }),
      );
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('returns sanitized details on Apprise 204 without leaking secrets', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
    try {
      const res = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({ type: 'apprise', url: 'http://apprise.local/notify/secret-key', config: {} });
      expect(res.status).toBe(500);
      expect(JSON.stringify(res.body)).toContain('HTTP 204');
      expect(JSON.stringify(res.body)).not.toContain('secret-key');
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('dispatches a templated test with substituted variables', async () => {
    const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
    vi.stubGlobal('fetch', fetchMock);
    try {
      const res = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({
          type: 'webhook',
          url: 'https://example.com/hooks/sencho',
          payload_template: '{"message": "{{message}}", "level": "{{level}}", "category": "{{category}}"}',
        });
      expect(res.status).toBe(200);
      const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
      expect(JSON.parse(String(init.body))).toEqual({
        message: '🔌 Test Notification from Sencho!',
        level: 'info',
        category: 'system',
      });
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('rejects an unknown variable on a templated test without dispatching', async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);
    try {
      const res = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({
          type: 'webhook',
          url: 'https://example.com/hooks/sencho',
          payload_template: '{"a": "{{nope}}"}',
        });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/Unknown template variable/);
      expect(fetchMock).not.toHaveBeenCalled();
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('rejects an over-length template on a templated test', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', authCookie)
      .send({
        type: 'webhook',
        url: 'https://example.com/hooks/sencho',
        payload_template: `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 9)}"}`,
      });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('8000 characters or fewer');
  });

  it('keeps the built-in payload when the test template is blank', async () => {
    const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
    vi.stubGlobal('fetch', fetchMock);
    try {
      const res = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({ type: 'webhook', url: 'https://example.com/hooks/sencho', payload_template: '   ' });
      expect(res.status).toBe(200);
      const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
      const body = JSON.parse(String(init.body)) as { source?: string };
      expect(body.source).toBe('sencho');
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('merges Apprise destinations into a templated test dispatch', async () => {
    const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
    vi.stubGlobal('fetch', fetchMock);
    try {
      const res = await request(app)
        .post('/api/notifications/test')
        .set('Cookie', authCookie)
        .send({
          type: 'apprise',
          url: 'http://apprise.local/notify/test-key',
          config: { tags: 'ops' },
          payload_template: '{"title": "{{level}}"}',
        });
      expect(res.status).toBe(200);
      const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
      expect(JSON.parse(String(init.body))).toEqual({ title: 'info', tag: 'ops' });
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('rejects an Apprise templated test that carries urls', async () => {
    const res = await request(app)
      .post('/api/notifications/test')
      .set('Cookie', authCookie)
      .send({
        type: 'apprise',
        url: 'http://apprise.local/notify',
        config: { urls: 'discord://token@id' },
        payload_template: '{"urls": "discord://token@id"}',
      });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('urls');
  });
});
