Первая версия мобильного приложения
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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(),
|
||||
}));
|
||||
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(),
|
||||
}));
|
||||
vi.mock("expo-sharing", () => ({
|
||||
isAvailableAsync: vi.fn(),
|
||||
shareAsync: vi.fn(),
|
||||
}));
|
||||
vi.mock("react-native", () => ({
|
||||
Platform: { OS: "android", Version: "test", constants: {} },
|
||||
AppState: { currentState: "active" },
|
||||
}));
|
||||
|
||||
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<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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user