153 lines
4.8 KiB
TypeScript
153 lines
4.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
vi.mock("../../src/auth", () => ({
|
||
getAccessToken: () => "test.jwt",
|
||
refreshTokens: vi.fn(async () => undefined),
|
||
}));
|
||
vi.mock("../../src/config", () => ({
|
||
env: { apiBaseUrl: "https://example.test" },
|
||
}));
|
||
vi.mock("../../src/services", () => ({
|
||
dialogApi: { messages: vi.fn(async () => ({ items: [], next_cursor: null })) },
|
||
}));
|
||
|
||
import {
|
||
NOTIFICATION_OUTAGE_MS,
|
||
NOTIFICATION_POLL_INTERVAL_MS,
|
||
NotificationRealtimeClient,
|
||
} from "../../src/realtime";
|
||
import {
|
||
actionDialogId,
|
||
actionUrl,
|
||
formatNotificationPrice,
|
||
notificationIcon,
|
||
notificationPalette,
|
||
typeMap,
|
||
} from "../../src/notification-presenter";
|
||
import type { NotificationType } from "../../src/types";
|
||
|
||
class MockWebSocket {
|
||
static readonly OPEN = 1;
|
||
static instances: MockWebSocket[] = [];
|
||
readonly sent: string[] = [];
|
||
readyState = MockWebSocket.OPEN;
|
||
onopen?: () => void;
|
||
onmessage?: (event: { data: string }) => void;
|
||
onclose?: (event: { code: number }) => void;
|
||
onerror?: () => void;
|
||
|
||
constructor(readonly url: string, readonly protocols: string[]) {
|
||
MockWebSocket.instances.push(this);
|
||
}
|
||
|
||
send(value: string) {
|
||
this.sent.push(value);
|
||
}
|
||
|
||
close() {}
|
||
}
|
||
|
||
describe("notification catalog presentation", () => {
|
||
it("использует neutral и bell для неизвестных значений", () => {
|
||
expect(notificationPalette("future-token")).toEqual(notificationPalette("neutral"));
|
||
expect(notificationIcon("future-icon")).toBe("bell");
|
||
expect(notificationIcon(null)).toBe("bell");
|
||
});
|
||
|
||
it("рендерит новый вид только по данным каталога", () => {
|
||
const type: NotificationType = {
|
||
code: "future_type",
|
||
label: "Новый вид",
|
||
color_token: "info",
|
||
icon_code: "news",
|
||
cta_text: "Открыть",
|
||
cta_action: "open_detail",
|
||
countable: true,
|
||
contour: "P",
|
||
button_primary: { code: "gotit", label: "Понятно" },
|
||
button_secondary: null,
|
||
};
|
||
expect(typeMap([type]).get("future_type")).toEqual(type);
|
||
expect(notificationPalette(type.color_token).accent).toBe("#2563eb");
|
||
});
|
||
|
||
it("форматирует цену в рублях и безопасно игнорирует мусор", () => {
|
||
expect(formatNotificationPrice("1500")).toContain("1 500");
|
||
expect(formatNotificationPrice("not-a-number")).toBeNull();
|
||
});
|
||
|
||
it("читает результат CTA из backend state response", () => {
|
||
const base = {
|
||
notification_id: "notification-1",
|
||
lifecycle_status: "active" as const,
|
||
visibility: "visible" as const,
|
||
is_read: true,
|
||
close_reason: null,
|
||
date_expired: null,
|
||
unread_count: 0,
|
||
};
|
||
expect(actionUrl({ ...base, result: { action: "open_url", url: "https://pay.test" } }))
|
||
.toBe("https://pay.test");
|
||
expect(actionDialogId({
|
||
...base,
|
||
result: {
|
||
action: "chat_message_sent",
|
||
message: {
|
||
message_id: "message-1",
|
||
dialog_id: "dialog-1",
|
||
sender_type: "client",
|
||
content_kind: "text",
|
||
text: "Тест",
|
||
attachments: [],
|
||
safety_status: "allowed",
|
||
delivery_status: "delivered",
|
||
created_at: "2026-07-27T12:00:00Z",
|
||
},
|
||
},
|
||
})).toBe("dialog-1");
|
||
expect(actionUrl({ ...base, result: null })).toBeUndefined();
|
||
expect(actionDialogId({ ...base, result: null })).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("notification realtime degradation", () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
MockWebSocket.instances = [];
|
||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("подписывается с notifications:true", () => {
|
||
const client = new NotificationRealtimeClient(vi.fn(), vi.fn(async () => undefined), vi.fn());
|
||
client.start();
|
||
const socket = MockWebSocket.instances[0]!;
|
||
socket.onopen?.();
|
||
expect(JSON.parse(socket.sent[0]!)).toEqual({
|
||
type: "subscribe",
|
||
dialog_ids: [],
|
||
notifications: true,
|
||
});
|
||
client.stop();
|
||
});
|
||
|
||
it("после 30 секунд включает polling с интервалом 60 секунд", async () => {
|
||
const reconcile = vi.fn(async () => undefined);
|
||
const state = vi.fn();
|
||
const client = new NotificationRealtimeClient(vi.fn(), reconcile, state);
|
||
client.start();
|
||
MockWebSocket.instances[0]!.onclose?.({ code: 1006 });
|
||
|
||
await vi.advanceTimersByTimeAsync(NOTIFICATION_OUTAGE_MS);
|
||
expect(state).toHaveBeenCalledWith("polling");
|
||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||
|
||
await vi.advanceTimersByTimeAsync(NOTIFICATION_POLL_INTERVAL_MS);
|
||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||
client.stop();
|
||
});
|
||
});
|