Files
han-app/codebase/backend/frontend-test-site/tests/unit/core.test.ts
T

180 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from "vitest";
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 () => null),
setItemAsync: vi.fn(async () => undefined),
deleteItemAsync: vi.fn(async () => undefined),
}));
vi.mock("expo-web-browser", () => ({
maybeCompleteAuthSession: vi.fn(),
}));
vi.mock("react-native", () => ({
Platform: { OS: "web", Version: "test", constants: {} },
}));
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 {
clearPendingTextIntent,
bindPendingFileIntent,
bindPendingTextIntent,
clearPendingFileIntent,
createPendingTextIntent,
loadPendingFileIntent,
loadPendingTextIntent,
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();
expect(localStorage.getItem("ux_session_id")).toBeNull();
});
});
describe("pending message intent", () => {
it("сохраняет ключи повтора и очищается после завершения", () => {
const intent = createPendingTextIntent("Сообщение после входа");
savePendingTextIntent(intent);
expect(loadPendingTextIntent()).toEqual(intent);
clearPendingTextIntent();
expect(loadPendingTextIntent()).toBeNull();
});
it("игнорирует повреждённое значение", () => {
sessionStorage.setItem("han.pending-message", "{\"text\":42}");
expect(loadPendingTextIntent()).toBeNull();
clearPendingTextIntent();
});
it("привязывает отложенный текст и файл к созданному диалогу", () => {
const textIntent = createPendingTextIntent("После входа");
bindPendingTextIntent(textIntent, "dialog-1");
const file = new File(["test"], "test.txt", { type: "text/plain" });
savePendingFileIntent({ file, dialogKey: "file-dialog-key", messageKey: "file-key" });
bindPendingFileIntent("dialog-1");
expect(loadPendingTextIntent()?.dialogId).toBe("dialog-1");
expect(loadPendingFileIntent("dialog-1")?.file).toBe(file);
clearPendingTextIntent();
clearPendingFileIntent();
});
});
describe("message UX rules", () => {
it("нормализует текст и проверяет динамический лимит", () => {
expect(normalizeMessageText(" ")).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<number>();
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.<base64url(jwt)>", () => {
const protocol = websocketJwtProtocol("header.payload.signature");
expect(protocol).toBe("han.jwt.aGVhZGVyLnBheWxvYWQuc2lnbmF0dXJl");
expect(protocol).not.toContain("=");
});
});
describe("OIDC device metadata", () => {
it("создаёт стабильный web UUID и передаёт доступные han_* поля", async () => {
const values = new Map<string, string>();
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: "web",
han_app_version: "1.0.0",
});
});
});