Files
gitea-mirror/src/lib/notification-service.test.ts
ARUNAVO RAY 5d2462e5a0 feat: add notification system with Ntfy.sh and Apprise support (#238)
* feat: add notification system with Ntfy.sh and Apprise providers (#231)

Add push notification support for mirror job events with two providers:

- Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support
- Apprise API: aggregator gateway supporting 100+ notification services

Includes database migration (0010), settings UI tab, test endpoint,
auto-save integration, token encryption, and comprehensive tests.
Notifications are fire-and-forget and never block the mirror flow.

* fix: address review findings for notification system

- Fix silent catch in GET handler that returned ciphertext to UI,
  causing double-encryption on next save. Now clears token to ""
  on decryption failure instead.
- Add Zod schema validation to test notification endpoint, following
  project API route pattern guidelines.
- Mark notifyOnNewRepo toggle as "coming soon" with disabled state,
  since the backend doesn't yet emit new_repo events. The schema
  and type support is in place for when it's implemented.

* fix notification gating and config validation

* trim sync notification details
2026-03-18 18:36:51 +05:30

222 lines
5.6 KiB
TypeScript

import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock fetch globally before importing the module
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
// Mock encryption module
mock.module("@/lib/utils/encryption", () => ({
encrypt: (val: string) => val,
decrypt: (val: string) => val,
isEncrypted: () => false,
}));
// Import after mocks are set up — db is already mocked via setup.bun.ts
import { sendNotification, testNotification } from "./notification-service";
import type { NotificationConfig } from "@/types/config";
describe("sendNotification", () => {
test("sends ntfy notification when provider is ntfy", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/test-topic");
});
test("sends apprise notification when provider is apprise", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "apprise",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
apprise: {
url: "http://apprise:8000",
token: "my-token",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
// Should not throw
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
});
test("skips notification when ntfy topic is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "",
priority: "default",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when apprise URL is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "apprise",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
apprise: {
url: "",
token: "my-token",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
test("returns success when notification is sent", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
});
test("returns error when topic is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toContain("topic");
});
test("returns error when fetch fails", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("bad request", { status: 400 }))
);
globalThis.fetch = mockFetch as any;
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
test("returns error for unknown provider", async () => {
const config = {
enabled: true,
provider: "unknown" as any,
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown provider");
});
});