146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
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 { reconcileMessages } from "../../src/reconcile";
|
|
import { sessionMemory } from "../../src/session";
|
|
import { SingleFlight } from "../../src/single-flight";
|
|
import { websocketJwtProtocol } from "../../src/realtime";
|
|
import {
|
|
clearPendingTextIntent,
|
|
createPendingTextIntent,
|
|
loadPendingTextIntent,
|
|
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();
|
|
});
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|