Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
test("PWA-манифест и Service Worker доступны", async ({ request }) => {
const manifestResponse = await request.get("/manifest.json");
expect(manifestResponse.ok()).toBe(true);
const manifest = await manifestResponse.json();
expect(manifest).toMatchObject({
name: "HAN Chat",
display: "standalone",
icons: expect.arrayContaining([
expect.objectContaining({ src: "/icon-192.png", sizes: "192x192" }),
expect.objectContaining({ src: "/icon-512.png", sizes: "512x512" }),
]),
});
const serviceWorkerResponse = await request.get("/service-worker.js");
expect(serviceWorkerResponse.ok()).toBe(true);
expect(await serviceWorkerResponse.text()).toContain("precacheAndRoute");
});
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/public/app-config", (route) => route.fulfill({
json: {
auth: { phone_enabled: true, password_enabled: false },
operator: { call_phone: "+74950000000" },
messages: { max_text_length: 4000 },
ux: { idle_timeout_minutes: 15 },
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
consents: {
personal_data: {
required: true,
version: "2026-07-01",
document_url: "https://example.test/personal",
privacy_policy_document_url: "https://example.test/privacy",
},
user_agreement: { required: true, version: "2026-07-01", document_url: "https://example.test/agreement" },
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
},
},
}));
await page.route("**/api/v1/public/content", (route) => route.fulfill({
json: {
locale: "ru",
texts: { welcome: "Добро пожаловать в HAN Chat" },
popular_questions: [{ id: "visa", mnemonic: "visa", text: "Как оформить визу?" }],
version: "1",
},
}));
});
test("гостевой экран загружает публичный контент", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Привет! Я HAN" })).toBeVisible();
await expect(page.getByText("Добро пожаловать в HAN Chat")).toBeVisible();
await expect(page.getByRole("button", { name: "Как оформить визу?" })).toBeVisible();
await expect(page.getByRole("button", { name: "Голосовое сообщение" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Прикрепить файл" })).toBeVisible();
await expect(page.getByText("Продление патента через 14 дней")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Чат" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Оператор" })).toBeEnabled();
await expect(page.getByText("+74950000000")).toHaveCount(0);
await expect(page.getByText(/Гость/)).toHaveCount(0);
});
test("Колокольчик открывает центр уведомлений", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Уведомления" }).click();
await expect(page).toHaveURL(/\/notifications$/);
await expect(page.getByRole("heading", { name: "Центр уведомлений" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Уведомления доступны после входа" })).toBeVisible();
await expect(page.getByRole("button", { name: "Авторизоваться" })).toBeVisible();
});
test("первое сообщение требует обязательные согласия", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Сообщение").fill("Здравствуйте");
await page.getByRole("button", { name: "Отправить" }).click();
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
await expect(page.getByText("Согласие на обработку ПД")).toBeVisible();
await expect(page.getByText("Политика обработки ПД")).toBeVisible();
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
});
test("Enter отправляет введённое сообщение", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Сообщение").fill("Отправка с клавиатуры");
await page.getByLabel("Сообщение").press("Enter");
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
});
test("счётчик показывает и ограничивает 4000 символов", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Сообщение").fill("а".repeat(4001));
await expect(page.getByText("4001/4000")).toBeVisible();
await expect(page.getByRole("button", { name: "Отправить" })).toBeDisabled();
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toHaveCount(0);
});
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
let protectedCalls = 0;
await page.route("**/api/v1/me", (route) => { protectedCalls++; return route.abort(); });
await page.goto("/profile");
await expect(page.getByRole("heading", { name: "Профиль доступен после входа" })).toBeVisible();
await expect(page.getByText("Авторизуйтесь, чтобы видеть личные данные и документы компании.")).toBeVisible();
await expect(page.getByRole("button", { name: "Авторизоваться" })).toBeVisible();
expect(protectedCalls).toBe(0);
});
test("гостевой чат предлагает авторизоваться", async ({ page }) => {
await page.goto("/dialogs");
await expect(page.getByRole("heading", { name: "Чат доступен после входа" })).toBeVisible();
await expect(page.getByText("Авторизуйтесь, чтобы переписываться с оператором и получать ответы.")).toBeVisible();
await page.getByRole("button", { name: "Авторизоваться" }).click();
await expect(page).toHaveURL(/\/\?authorize=1$/);
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
});
@@ -0,0 +1,179 @@
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",
});
});
});
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/auth", () => ({
getAccessToken: () => "test.jwt",
refreshTokens: vi.fn(async () => undefined),
}));
vi.mock("../../src/config", () => ({
env: { apiBaseUrl: "https://example.test" },
}));
vi.mock("../../src/services", () => ({
dialogApi: { messages: vi.fn(async () => ({ items: [], next_cursor: null })) },
}));
import {
NOTIFICATION_OUTAGE_MS,
NOTIFICATION_POLL_INTERVAL_MS,
NotificationRealtimeClient,
} from "../../src/realtime";
import {
actionDialogId,
actionUrl,
formatNotificationPrice,
notificationIcon,
notificationPalette,
typeMap,
} from "../../src/notification-presenter";
import type { NotificationType } from "../../src/types";
class MockWebSocket {
static readonly OPEN = 1;
static instances: MockWebSocket[] = [];
readonly sent: string[] = [];
readyState = MockWebSocket.OPEN;
onopen?: () => void;
onmessage?: (event: { data: string }) => void;
onclose?: (event: { code: number }) => void;
onerror?: () => void;
constructor(readonly url: string, readonly protocols: string[]) {
MockWebSocket.instances.push(this);
}
send(value: string) {
this.sent.push(value);
}
close() {}
}
describe("notification catalog presentation", () => {
it("использует neutral и bell для неизвестных значений", () => {
expect(notificationPalette("future-token")).toEqual(notificationPalette("neutral"));
expect(notificationIcon("future-icon")).toBe("bell");
expect(notificationIcon(null)).toBe("bell");
});
it("рендерит новый вид только по данным каталога", () => {
const type: NotificationType = {
code: "future_type",
label: "Новый вид",
color_token: "info",
icon_code: "news",
cta_text: "Открыть",
cta_action: "open_detail",
countable: true,
contour: "P",
button_primary: { code: "gotit", label: "Понятно" },
button_secondary: null,
};
expect(typeMap([type]).get("future_type")).toEqual(type);
expect(notificationPalette(type.color_token).accent).toBe("#2563eb");
});
it("форматирует цену в рублях и безопасно игнорирует мусор", () => {
expect(formatNotificationPrice("1500")).toContain("1 500");
expect(formatNotificationPrice("not-a-number")).toBeNull();
});
it("читает результат CTA из backend state response", () => {
const base = {
notification_id: "notification-1",
lifecycle_status: "active" as const,
visibility: "visible" as const,
is_read: true,
close_reason: null,
date_expired: null,
unread_count: 0,
};
expect(actionUrl({ ...base, result: { action: "open_url", url: "https://pay.test" } }))
.toBe("https://pay.test");
expect(actionDialogId({
...base,
result: {
action: "chat_message_sent",
message: {
message_id: "message-1",
dialog_id: "dialog-1",
sender_type: "client",
content_kind: "text",
text: "Тест",
attachments: [],
safety_status: "allowed",
delivery_status: "delivered",
created_at: "2026-07-27T12:00:00Z",
},
},
})).toBe("dialog-1");
expect(actionUrl({ ...base, result: null })).toBeUndefined();
expect(actionDialogId({ ...base, result: null })).toBeUndefined();
});
});
describe("notification realtime degradation", () => {
beforeEach(() => {
vi.useFakeTimers();
MockWebSocket.instances = [];
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
vi.useRealTimers();
});
it("подписывается с notifications:true", () => {
const client = new NotificationRealtimeClient(vi.fn(), vi.fn(async () => undefined), vi.fn());
client.start();
const socket = MockWebSocket.instances[0]!;
socket.onopen?.();
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: "subscribe",
dialog_ids: [],
notifications: true,
});
client.stop();
});
it("после 30 секунд включает polling с интервалом 60 секунд", async () => {
const reconcile = vi.fn(async () => undefined);
const state = vi.fn();
const client = new NotificationRealtimeClient(vi.fn(), reconcile, state);
client.start();
MockWebSocket.instances[0]!.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(NOTIFICATION_OUTAGE_MS);
expect(state).toHaveBeenCalledWith("polling");
expect(reconcile).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(NOTIFICATION_POLL_INTERVAL_MS);
expect(reconcile).toHaveBeenCalledTimes(2);
client.stop();
});
});