import { describe, expect, it, vi } from "vitest"; const secureValues = vi.hoisted(() => new Map()); vi.mock("expo-constants", () => ({ default: { expoConfig: { version: "1.0.0" } }, })); vi.mock("expo-crypto", () => ({ CryptoDigestAlgorithm: { SHA256: "SHA-256" }, randomUUID: vi.fn(() => "123e4567-e89b-42d3-a456-426614174000"), digestStringAsync: vi.fn(async () => "stable-fingerprint"), })); vi.mock("expo-auth-session", () => ({ makeRedirectUri: vi.fn(() => "https://example.test/auth/callback"), })); vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(async (key: string) => secureValues.get(key) ?? null), setItemAsync: vi.fn(async (key: string, value: string) => { secureValues.set(key, value); }), deleteItemAsync: vi.fn(async (key: string) => { secureValues.delete(key); }), })); vi.mock("expo-web-browser", () => ({ maybeCompleteAuthSession: vi.fn(), openAuthSessionAsync: vi.fn(), openBrowserAsync: vi.fn(), })); vi.mock("expo-document-picker", () => ({ getDocumentAsync: vi.fn(), })); vi.mock("expo-file-system", () => ({ File: vi.fn(), Paths: { cache: "file:///cache" }, })); vi.mock("expo-file-system/legacy", () => ({ FileSystemUploadType: { BINARY_CONTENT: 0 }, createUploadTask: vi.fn(), downloadAsync: vi.fn(), })); vi.mock("expo-linking", () => ({ openURL: vi.fn(), addEventListener: vi.fn(() => ({ remove: vi.fn() })), })); vi.mock("expo-sharing", () => ({ isAvailableAsync: vi.fn(), shareAsync: vi.fn(), })); vi.mock("react-native", () => ({ Platform: { OS: "android", Version: "test", constants: {} }, AppState: { currentState: "active", addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, })); import { buildOidcDeviceMetadata } from "../../src/oidc-device"; import { ApiError, isMessageBlockedError } from "../../src/api"; import { messageFitsLimit, normalizeMessageText } from "../../src/message-text"; import { reconcileMessages } from "../../src/reconcile"; import { sessionMemory } from "../../src/session"; import { SingleFlight } from "../../src/single-flight"; import { websocketJwtProtocol } from "../../src/realtime"; import { toNativeFile } from "../../src/native-files"; import { clearPendingTextIntent, bindPendingFileIntent, bindPendingTextIntent, clearPendingFileIntent, createPendingTextIntent, loadPendingFileIntent, loadPendingTextIntent, restorePendingIntents, savePendingFileIntent, savePendingTextIntent, } from "../../src/pending-intent"; import type { Message } from "../../src/types"; const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({ message_id: id, dialog_id: "dialog", sender_type: "client", content_kind: "text", text: "Тест", attachments: [], safety_status: "allowed", delivery_status: status, created_at: createdAt, }); describe("reconcileMessages", () => { it("устраняет дубли, обновляет статус и сортирует сообщения", () => { const result = reconcileMessages( [message("2", "2026-01-02T00:00:00Z"), message("1", "2026-01-01T00:00:00Z")], [message("2", "2026-01-02T00:00:00Z", "delivered"), message("3", "2026-01-03T00:00:00Z")], ); expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]); expect(result[1]?.delivery_status).toBe("delivered"); }); it("сортирует realtime-сообщения по абсолютному времени при разных часовых поясах", () => { const result = reconcileMessages( [message("client", "2026-07-21T15:48:00+03:00")], [message("company", "2026-07-21T12:49:00Z")], ); expect(result.map((item) => item.message_id)).toEqual(["client", "company"]); }); }); describe("UX session memory", () => { it("хранит идентификатор только в памяти и очищает его", () => { sessionMemory.set("ux-test"); expect(sessionMemory.id).toBe("ux-test"); sessionMemory.clear(); expect(sessionMemory.id).toBeNull(); }); }); describe("pending message intent", () => { it("сохраняет ключи повтора и очищается после завершения", async () => { const intent = createPendingTextIntent("Сообщение после входа"); await savePendingTextIntent(intent); expect(loadPendingTextIntent()).toEqual(intent); await clearPendingTextIntent(); expect(loadPendingTextIntent()).toBeNull(); }); it("игнорирует повреждённое значение", async () => { await clearPendingTextIntent(); secureValues.set("han.pending-message", "{\"text\":42}"); await restorePendingIntents(); expect(loadPendingTextIntent()).toBeNull(); await clearPendingTextIntent(); }); it("привязывает отложенный текст и файл к созданному диалогу", async () => { const textIntent = createPendingTextIntent("После входа"); await bindPendingTextIntent(textIntent, "dialog-1"); const file = { uri: "file:///cache/test.txt", name: "test.txt", mimeType: "text/plain", size: 4 }; await savePendingFileIntent({ file, dialogKey: "file-dialog-key", messageKey: "file-key" }); await bindPendingFileIntent("dialog-1"); expect(loadPendingTextIntent()?.dialogId).toBe("dialog-1"); expect(loadPendingFileIntent("dialog-1")?.file).toBe(file); await clearPendingTextIntent(); await clearPendingFileIntent(); }); }); describe("native file descriptor", () => { it("нормализует поля DocumentPicker и подставляет безопасные значения", () => { expect(toNativeFile({ uri: "file:///cache/document", name: "document", mimeType: null, size: null, })).toEqual({ uri: "file:///cache/document", name: "document", mimeType: "application/octet-stream", size: 0, }); }); }); describe("message UX rules", () => { it("нормализует текст и проверяет динамический лимит", () => { expect(normalizeMessageText(" A ")).toBe("A"); expect(messageFitsLimit("1234", 4)).toBe(true); expect(messageFitsLimit("12345", 4)).toBe(false); }); it("отличает блокировку message-safety от технической ошибки", () => { expect(isMessageBlockedError(new ApiError(422, "message_blocked", "blocked"))).toBe(true); expect(isMessageBlockedError(new ApiError(503, "dependency_unavailable", "failed"))).toBe(false); }); }); describe("SingleFlight", () => { it("объединяет параллельные refresh операции", async () => { const flight = new SingleFlight(); let calls = 0; const operation = async () => { calls++; await Promise.resolve(); return 42; }; const [first, second, third] = await Promise.all([ flight.run(operation), flight.run(operation), flight.run(operation), ]); expect([first, second, third]).toEqual([42, 42, 42]); expect(calls).toBe(1); }); }); describe("WebSocket authentication protocol", () => { it("кодирует JWT как canonical han.jwt.", () => { const protocol = websocketJwtProtocol("header.payload.signature"); expect(protocol).toBe("han.jwt.aGVhZGVyLnBheWxvYWQuc2lnbmF0dXJl"); expect(protocol).not.toContain("="); }); }); describe("OIDC device metadata", () => { it("создаёт стабильный Android UUID и передаёт доступные han_* поля", async () => { const values = new Map(); const store = { get: async (key: string) => values.get(key) ?? null, set: async (key: string, value: string) => { values.set(key, value); }, }; const first = await buildOidcDeviceMetadata(store); const second = await buildOidcDeviceMetadata(store); expect(first.han_device_id).toBe("123e4567-e89b-42d3-a456-426614174000"); expect(second.han_device_id).toBe(first.han_device_id); expect(first).toMatchObject({ han_fingerprint: "stable-fingerprint", han_platform: "android", han_app_version: "1.0.0", }); }); });