Files
han-app/VM4_Expo-mobile/tests/unit/core.test.ts
T
2026-08-26 11:05:32 +03:00

224 lines
8.1 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";
const secureValues = vi.hoisted(() => new Map<string, string>());
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(" ")).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("создаёт стабильный Android 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: "android",
han_app_version: "1.0.0",
});
});
});