diff --git a/guides/reference/errors.md b/guides/reference/errors.md index 94208b4..78262f9 100644 --- a/guides/reference/errors.md +++ b/guides/reference/errors.md @@ -126,9 +126,9 @@ isItdServerError(v) `ACCOUNT_CURRENT_PASSWORD_INCORRECT`, `ACCOUNT_EMAIL_DOMAIN_NOT_ALLOWED`. - **Сессия:** `SESSION_EXPIRED`, `SESSION_REVOKED`, `SESSION_INVALID_REFRESH_TOKEN`, `SESSION_NOT_FOUND`, `REFRESH_TOKEN_MISSING`. -- **Профиль/контент:** `PROFILE_USERNAME_TAKEN`, `PROFILE_USERNAME_RESERVED`, - `PROFILE_RESTRICTION_ACTIVE`, `PROFILE_MODIFICATION_RESTRICTED`, `CONTENT_MODERATION_FAILED`, - `WRITE_ACCESS_RESTRICTED`. +- **Профиль/контент:** `USERNAME_TAKEN` (смена имени в `updateMe()`), `PROFILE_USERNAME_TAKEN`, + `PROFILE_USERNAME_RESERVED`, `PROFILE_RESTRICTION_ACTIVE`, `PROFILE_MODIFICATION_RESTRICTED`, + `CONTENT_MODERATION_FAILED`, `WRITE_ACCESS_RESTRICTED`. - **Файлы:** `FILE_TOO_LARGE`, `UNSUPPORTED_FILE_TYPE`, `UPLOAD_FAILED`, `VIDEO_REQUIRES_VERIFICATION`. - **Телефон:** `PHONE_VERIFICATION_REQUIRED`. diff --git a/guides/reference/models.md b/guides/reference/models.md index 1aec37b..9f0e4ca 100644 --- a/guides/reference/models.md +++ b/guides/reference/models.md @@ -5,8 +5,9 @@ Особенности, о которых легко забыть: -- **`avatar` — это эмодзи, а не URL.** На итд.com аватар — символ клана (`🩵`, `🦎`). - Отрисовывать его нужно как текст. Поле `banner` содержит URL изображения или `null`. +- **`avatar` — обычно эмодзи.** Как правило, аватар — символ клана (`🩵`, `🦎`), его отрисовывают + как текст. Он может быть и URL изображения — тогда клан хранится в `clanAvatar` профиля. + Поле `banner` содержит URL изображения или `null`. - **`UserRef`** = UUID или username; **`UserId`** = строго UUID. ```ts @@ -26,7 +27,7 @@ interface Author { id: UserId; username: string; displayName: string; - avatar: string; // эмодзи, не URL + avatar: string; // обычно эмодзи, может быть URL verified: boolean; pin?: Pin | null; // активный значок hasNuksta?: boolean; // премиум-подписка @@ -72,7 +73,8 @@ interface UserSummary { ```ts interface MyProfile { id: UserId; username: string; displayName: string; - avatar: string; banner: string | null; bio: string; + avatar: string; clanAvatar?: string; // эмодзи клана + banner: string | null; bio: string; verified: boolean; pin?: Pin | null; wallAccess: WallAccess; // кто может писать на стену likesVisibility: LikesVisibility; // кто видит реакции @@ -99,7 +101,7 @@ interface SubscriptionState { ```ts interface AuthUser { id: UserId; username: string; displayName: string; - avatar: string; bio: string; verified: boolean; + avatar: string; clanAvatar?: string; bio: string; verified: boolean; isPhoneVerified: boolean; roles: string[]; } @@ -120,7 +122,8 @@ interface AuthState { ```ts interface PublicProfile { id: UserId; username: string; displayName: string; - avatar: string; banner: string | null; bio: string; + avatar: string; clanAvatar?: string; // эмодзи клана + banner: string | null; bio: string; verified: boolean; pin?: Pin | null; wallAccess: WallAccess; likesVisibility: LikesVisibility; followersCount: number; followingCount: number; postsCount: number; @@ -168,6 +171,7 @@ interface PrivacySettings { isPrivate: boolean; // подписка требует одобрения wallAccess: WallAccess; likesVisibility: LikesVisibility; + messageAccess: MessageAccess; // кто может писать личные сообщения showLastSeen: boolean; } ``` @@ -186,7 +190,7 @@ interface FollowResult { ```ts interface Clan { - avatar: string; // эмодзи клана + avatar: string; // эмодзи клана = clanAvatar участников memberCount: number; } ``` @@ -206,7 +210,7 @@ interface Post { wallRecipientId: UserId | null; // чья стена, если пост не у себя wallRecipient?: Author | null; // владелец стены isLiked: boolean; isReposted: boolean; isViewed?: boolean; isOwner: boolean; - originalPost?: Post | null; // если это репост + originalPost?: OriginalPost | null; // если это репост poll?: Poll | null; dominantEmoji?: string | null; // преобладающая реакция editedAt?: IsoDate | null; @@ -216,6 +220,27 @@ interface Post { } ``` +### OriginalPost + +Пост, на который ссылается репост. Сервер отдаёт его на один уровень: у репоста репоста +`originalPost` — промежуточный репост, без собственного `originalPost`. `createdAt` сервер +присылает в формате PostgreSQL (`2026-10-04 22:32:04.381097+03`), библиотека приводит его к ISO. + +```ts +interface OriginalPost { + id: string; + content: string; + spans: Span[]; + author: Author; + attachments: Attachment[]; + likesCount: number; commentsCount: number; repostsCount: number; viewsCount: number; + isDeleted: boolean; + dominantEmoji?: string | null; + createdAt: IsoDate; + vs?: string; +} +``` + ### Comment ```ts @@ -359,6 +384,7 @@ interface Notification { interface NotificationSettings { enabled: boolean; // общий выключатель sound: boolean; + messages: boolean; // личные сообщения follows: boolean; wallPosts: boolean; likes: boolean; diff --git a/guides/reference/users.md b/guides/reference/users.md index 15d1470..629caeb 100644 --- a/guides/reference/users.md +++ b/guides/reference/users.md @@ -63,9 +63,10 @@ get(user: UserRef): Promise Профиль пользователя по UUID или имени. См. [`PublicProfile`](./models.md#publicprofile). ```ts -checkUsername(username: string): Promise +checkUsername(username: string): Promise ``` -Свободно ли имя пользователя. +Свободно ли имя пользователя. Своё текущее имя сервер считает занятым; при неверном формате +в ответе есть `reason: UsernameUnavailableReason.InvalidFormat`. ## Подписки @@ -93,10 +94,12 @@ iterateFollowing(user: UserRef, params?: UserListParams): Paginator ``` Подписчики и подписки. См. [`UserSummary`](./models.md#usersummary). -> ⚠️ **Сервер эти списки не листает.** Возвращаются первые 20 записей: `page` игнорируется, +Свои списки листаются обычным образом: `page`, `limit` до 20, `hasMore` и `total`. + +> ⚠️ **Чужие списки сервер не листает.** Возвращаются первые 20 записей: `page` игнорируется, > `limit` больше 20 молча уменьшается, `hasMore` всегда `false`. Полю `total` доверять тоже -> нельзя — оно расходится с `followersCount` из профиля. Методы-итераторы закончатся после -> первых 20 записей и оставлены на случай, если пагинацию починят. +> нельзя — оно расходится с `followersCount` из профиля. Итератор по чужому списку закончится +> после первых 20 записей. ## Блокировки @@ -154,15 +157,20 @@ topClans(): Promise interface UpdateProfileInput { displayName?: string; username?: string; - avatar?: string; // эмодзи-символ клана, а не URL картинки + avatar?: string; // эмодзи-символ клана bio?: string; bannerId?: string | null; // ID загруженного файла; null удаляет баннер } type UpdatePrivacyInput = Partial; +interface UsernameAvailability { + available: boolean; + reason?: UsernameUnavailableReason; // только при неверном формате +} + interface UserListParams { limit?: number; // > 20 сервер зажимает до 20 - page?: number; // сервер игнорирует + page?: number; // для чужого профиля сервер игнорирует } ``` diff --git a/guides/web/packages/testing.md b/guides/web/packages/testing.md index 86c4c85..7a3014c 100644 --- a/guides/web/packages/testing.md +++ b/guides/web/packages/testing.md @@ -181,18 +181,38 @@ const itd = new ItdClient({ Поддерживаются: -- свой и чужой профиль, обновление и деактивация; -- создание, чтение, изменение, удаление и восстановление записей; -- лента и стена с устойчивой курсорной пагинацией; +- свой и чужой профиль, обновление, смена имени, `checkUsername()` и деактивация; +- подписка и отписка, списки подписчиков и подписок, топ кланов; +- ленты: общая, подписок и клана, стена и лайкнутые записи с устойчивой курсорной пагинацией; +- создание, чтение, изменение, удаление и восстановление записей, разметка `spans`; +- запись на чужую стену по `wallAccess`; +- репосты, в том числе репост репоста, и их отмена; - комментарии, ответы, их изменение, удаление и восстановление; - реакции на записи и комментарии; -- подписка и отписка; +- хэштеги из разметки записей, тренды и записи по хэштегу; +- поиск пользователей и хэштегов; - список, счётчик и отметка уведомлений прочитанными; - несколько клиентов с одним состоянием; - `snapshot()` для независимого снимка и `reset()` для возврата к исходным данным. -Сервер покрывает перечисленные выше пользовательские сценарии. Файлы, медиа, особенности -старых ответов и событийные API проверяются отдельными сценариями. Неизвестный маршрут +Ссылки между пользователями, записями и уведомлениями хранятся по `id`. После смены имени +`clientOptions({ as })` находит пользователя по `id` или по новому имени, а уведомления +показывают участника с текущим именем. + +Модель сервера упрощает API в нескольких местах: + +- ленты не моделируют рекомендации: `popular` — все записи по дате, тренды — хэштеги по дате + последней записи; +- клан — `clanAvatar` пользователя, а без него — эмодзи-аватар; +- списки подписчиков и подписок листаются по `page` для любого профиля; +- `likesVisibility` и `wallAccess` применяются без заявок закрытого профиля; +- деактивированный пользователь виден только себе: остальные получают `404`, его записи и + комментарии скрыты, а сам он может только читать и восстановить аккаунт; +- вложения и опросы при создании записи отклоняются с `400`. + +Файлы, медиа, приватность, блокировки, закрепление записей, настройки уведомлений и +событийные API сервер не моделирует: их проверяют через `createMockFetch()` или +`createMockOperations()`. Неизвестный маршрут возвращает `501` и код `MOCK_ROUTE_NOT_IMPLEMENTED`; в конце теста можно вызвать `server.assertNoUnsupportedRequests()`. diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index 5f3f5ea..b72fddd 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -87,6 +87,7 @@ declare module 'itd-api' { } interface Post extends CryptoDecodedObject {} + interface OriginalPost extends CryptoDecodedObject {} interface PostUpdateResult extends CryptoDecodedObject {} interface Comment extends CryptoDecodedObject {} interface CommentUpdateResult extends CryptoDecodedObject {} diff --git a/packages/crypto/test/plugin.test.ts b/packages/crypto/test/plugin.test.ts index 63dc0cf..3913060 100644 --- a/packages/crypto/test/plugin.test.ts +++ b/packages/crypto/test/plugin.test.ts @@ -92,6 +92,34 @@ describe('реестр cipher', () => { }); describe('шифрование запроса', () => { + it('расшифровывает исходный пост внутри репоста', async () => { + const { itd, calls } = makeClient((call, index) => + index === 0 + ? { id: 'root', ...call.body } + : { + id: 'repost', + content: '', + spans: [], + originalPost: { + id: 'root', + content: calls[0]?.body.content, + spans: calls[0]?.body.spans, + createdAt: '2026-10-04 22:32:04+03', + }, + }, + ); + itd.use(crypt()); + await itd.posts.create({ + content: 'видно секрет', + spans: [{ type: 'crypto', cipher: 'invisible', offset: 6, length: 6 }], + }); + + const post = await itd.posts.get('repost'); + + expect(post.originalPost?.decoded?.content?.text).toBe('видно секрет'); + expect(post.originalPost?.createdAt).toBe('2026-10-04T19:32:04.000Z'); + }); + it('заменяет crypto span на frame и восстанавливает plaintext без мутации входа', async () => { const inputSpans: Span[] = [ { type: 'bold', offset: 6, length: 6 }, diff --git a/packages/hydrate/src/index.ts b/packages/hydrate/src/index.ts index e1f9ad2..509c4fd 100644 --- a/packages/hydrate/src/index.ts +++ b/packages/hydrate/src/index.ts @@ -23,6 +23,7 @@ export { type HydratedNotificationFilter, type HydratedNotificationSelector, type HydratedNotificationsResource, + type HydratedOriginalPost, type HydratedPage, type HydratedPaginator, type HydratedPost, diff --git a/packages/hydrate/src/types.ts b/packages/hydrate/src/types.ts index 3291340..43823ce 100644 --- a/packages/hydrate/src/types.ts +++ b/packages/hydrate/src/types.ts @@ -25,6 +25,7 @@ import type { NotificationUpdate, NotificationUpdateOfType, NotificationUpdateType, + OriginalPost, Page, Paginator, Post, @@ -253,6 +254,12 @@ export type HydratedComment = HydratedModel = HydratedModel; +/** Пост, на который ссылается репост, с теми же методами, что и у поста. */ +export type HydratedOriginalPost = HydratedModel< + T, + HydratedPostActions +>; + /** Ссылка на комментарий из уведомления. */ export type HydratedCommentReference = Readonly<{ id: string }> & HydratedCommentActions; @@ -306,33 +313,35 @@ export type HydrateValue = T extends ? HydratedPage : T extends Post ? HydratedPost - : T extends Comment - ? HydratedComment - : T extends Notification - ? HydratedNotification - : T extends ShopDeliveryCity - ? HydratedShopDeliveryCity - : T extends MyProfile | PublicProfile - ? HydratedProfile - : T extends Author - ? HydratedAuthor - : T extends UserSummary - ? HydratedUserSummary - : T extends Actor - ? HydratedActor - : T extends CommentReplyTo - ? HydratedCommentReplyTo - : T extends Attachment - ? HydratedAttachment - : T extends { userId: string } | { username: string } - ? HydratedUserReference - : T extends readonly unknown[] - ? { [Key in keyof T]: HydrateValue } - : T extends (...args: never[]) => unknown - ? T - : T extends object - ? { [Key in keyof T]: HydrateValue } - : T; + : T extends OriginalPost + ? HydratedOriginalPost + : T extends Comment + ? HydratedComment + : T extends Notification + ? HydratedNotification + : T extends ShopDeliveryCity + ? HydratedShopDeliveryCity + : T extends MyProfile | PublicProfile + ? HydratedProfile + : T extends Author + ? HydratedAuthor + : T extends UserSummary + ? HydratedUserSummary + : T extends Actor + ? HydratedActor + : T extends CommentReplyTo + ? HydratedCommentReplyTo + : T extends Attachment + ? HydratedAttachment + : T extends { userId: string } | { username: string } + ? HydratedUserReference + : T extends readonly unknown[] + ? { [Key in keyof T]: HydrateValue } + : T extends (...args: never[]) => unknown + ? T + : T extends object + ? { [Key in keyof T]: HydrateValue } + : T; /** Событие уведомления с гидратированной моделью. */ export type HydratedNotificationEvent = HydratedModel; diff --git a/packages/hydrate/test/hydrate.test.ts b/packages/hydrate/test/hydrate.test.ts index e3b9ea6..00412ea 100644 --- a/packages/hydrate/test/hydrate.test.ts +++ b/packages/hydrate/test/hydrate.test.ts @@ -5,6 +5,7 @@ import { commentFixture, createMockFetch, createMockServer, + originalPostFixture, postFixture, } from '@itd-api/testing'; import { @@ -12,6 +13,7 @@ import { type ClientPlugin, ItdClient, NotificationType, + type OriginalPost, Paginator, type Post, type PublicProfile, @@ -73,7 +75,7 @@ describe('hydrateClient', () => { replyTo: { id: 'user-bob', username: 'bob', displayName: 'Боб' }, }); const comment = commentFixture({ id: 'comment-1', replies: [reply] }); - const originalPost = postFixture({ id: 'post-original' }); + const originalPost = originalPostFixture({ id: 'post-original' }); const source = postFixture({ id: 'post-root', originalPost, @@ -117,7 +119,7 @@ describe('hydrateClient', () => { it('добавляет городу доставки загрузку пунктов выдачи', async () => { const server = createMockServer(); - const itd = hydrateClient(new ItdClient(server.clientOptions({ as: 'test-user-1' }))); + const itd = hydrateClient(new ItdClient(server.clientOptions({ as: 'test_user_1' }))); const cities = await itd.shop.delivery.cities('Москва', 'RU'); const city = cities[0]; @@ -317,7 +319,7 @@ describe('hydrateClient', () => { it('привязывает одну модель к нужному клиенту', async () => { const shared = postFixture({ id: 'shared' }); - shared.originalPost = shared; + shared.originalPost = shared as unknown as OriginalPost; const calls: string[] = []; const make = (name: string) => { const raw = new ItdClient({ auth: 'test-token', retry: false, rateLimit: false }); diff --git a/packages/testing/README.md b/packages/testing/README.md index 01bd67f..96bd0d1 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -39,10 +39,11 @@ await bob.posts.like(post.id); expect((await alice.posts.get(post.id)).likesCount).toBe(1); ``` -Оба клиента работают с одним состоянием. Сервер поддерживает профили, записи, -комментарии и ответы, реакции, подписки, уведомления, магазин, удаление и восстановление. -Для магазина `seed` принимает `shopProducts` и `shopOrders`; доступны каталог, доставка, -создание и оплата заказов, а также доступ по коду из письма. +Оба клиента работают с одним состоянием. Сервер поддерживает профили и смену имени, +подписки и их списки, ленты (общую, подписок и клана), стены, записи с разметкой, репосты, +комментарии и ответы, реакции и лайкнутые записи, хэштеги, поиск, уведомления, деактивацию +и магазин. Связи хранятся по `id`, поэтому `as` принимает `id` или текущее имя пользователя. +Для магазина `seed` принимает `shopProducts` и `shopOrders`. Неизвестный маршрут возвращает статус `501` с кодом `MOCK_ROUTE_NOT_IMPLEMENTED`. Некорректные связи и повторяющиеся идентификаторы в `seed` отклоняются до изменения состояния. diff --git a/packages/testing/src/clock.ts b/packages/testing/src/clock/test-clock.factory.ts similarity index 83% rename from packages/testing/src/clock.ts rename to packages/testing/src/clock/test-clock.factory.ts index cdc7e31..a7a28f8 100644 --- a/packages/testing/src/clock.ts +++ b/packages/testing/src/clock/test-clock.factory.ts @@ -1,4 +1,4 @@ -import type { ItdClock } from 'itd-api'; +import type { TestClock } from './test-clock.types.js'; interface ScheduledTask { id: number; @@ -7,18 +7,6 @@ interface ScheduledTask { index: number; } -/** Управляемые часы для проверки тайм-аутов, повторов и событийных соединений. */ -export interface TestClock extends ItdClock { - /** Переводит часы вперёд и выполняет все наступившие задачи. */ - advanceBy(milliseconds: number): Promise; - /** Переводит часы к указанному моменту. */ - advanceTo(value: number | string | Date): Promise; - /** Число ещё не отменённых задач. */ - readonly pending: number; - /** Удаляет все запланированные задачи, не меняя текущее время. */ - clear(): void; -} - function timestamp(value: number | string | Date): number { const result = typeof value === 'number' ? value : new Date(value).getTime(); if (!Number.isFinite(result)) throw new TypeError('Начальное время должно быть корректной датой'); diff --git a/packages/testing/src/clock/test-clock.types.ts b/packages/testing/src/clock/test-clock.types.ts new file mode 100644 index 0000000..f619176 --- /dev/null +++ b/packages/testing/src/clock/test-clock.types.ts @@ -0,0 +1,13 @@ +import type { ItdClock } from 'itd-api'; + +/** Управляемые часы для проверки тайм-аутов, повторов и событийных соединений. */ +export interface TestClock extends ItdClock { + /** Переводит часы вперёд и выполняет все наступившие задачи. */ + advanceBy(milliseconds: number): Promise; + /** Переводит часы к указанному моменту. */ + advanceTo(value: number | string | Date): Promise; + /** Число ещё не отменённых задач. */ + readonly pending: number; + /** Удаляет все запланированные задачи, не меняя текущее время. */ + clear(): void; +} diff --git a/packages/testing/src/events/events.types.ts b/packages/testing/src/events/events.types.ts new file mode 100644 index 0000000..38a066c --- /dev/null +++ b/packages/testing/src/events/events.types.ts @@ -0,0 +1,6 @@ +import type { EventContext, NotificationEventContext } from 'itd-api'; + +export interface WaitForUpdateOptions { + signal?: AbortSignal; + predicate?: (context: C) => boolean; +} diff --git a/packages/testing/src/events/events.utils.ts b/packages/testing/src/events/events.utils.ts new file mode 100644 index 0000000..f77be64 --- /dev/null +++ b/packages/testing/src/events/events.utils.ts @@ -0,0 +1,28 @@ +import type { EventContext, NotificationEventContext } from 'itd-api'; +import type { WaitForUpdateOptions } from './events.types.js'; + +/** Дожидается одного подходящего обновления без привязки к средству запуска тестов. */ +export function waitForUpdate( + stream: { onUpdate(handler: (context: C) => void | Promise): () => void }, + options: WaitForUpdateOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(options.signal.reason); + return; + } + + let unsubscribe = () => {}; + const onAbort = () => { + unsubscribe(); + reject(options.signal?.reason); + }; + unsubscribe = stream.onUpdate((context) => { + if (options.predicate && !options.predicate(context)) return; + unsubscribe(); + options.signal?.removeEventListener('abort', onAbort); + resolve(context); + }); + options.signal?.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/packages/testing/src/events.ts b/packages/testing/src/events/mock-event.transport.ts similarity index 73% rename from packages/testing/src/events.ts rename to packages/testing/src/events/mock-event.transport.ts index d610b76..fe6f2a5 100644 --- a/packages/testing/src/events.ts +++ b/packages/testing/src/events/mock-event.transport.ts @@ -1,10 +1,4 @@ -import type { - EventContext, - EventTransport, - EventTransportContext, - Notification, - NotificationEventContext, -} from 'itd-api'; +import type { EventTransport, EventTransportContext, Notification } from 'itd-api'; interface ActiveConnection { readonly context: EventTransportContext; @@ -99,34 +93,3 @@ export class MockEventTransport implements EventTransport { return this.#active; } } - -export interface WaitForUpdateOptions { - signal?: AbortSignal; - predicate?: (context: C) => boolean; -} - -/** Дожидается одного подходящего обновления без привязки к средству запуска тестов. */ -export function waitForUpdate( - stream: { onUpdate(handler: (context: C) => void | Promise): () => void }, - options: WaitForUpdateOptions = {}, -): Promise { - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - reject(options.signal.reason); - return; - } - - let unsubscribe = () => {}; - const onAbort = () => { - unsubscribe(); - reject(options.signal?.reason); - }; - unsubscribe = stream.onUpdate((context) => { - if (options.predicate && !options.predicate(context)) return; - unsubscribe(); - options.signal?.removeEventListener('abort', onAbort); - resolve(context); - }); - options.signal?.addEventListener('abort', onAbort, { once: true }); - }); -} diff --git a/packages/testing/src/fetch/mock-fetch.factory.ts b/packages/testing/src/fetch/mock-fetch.factory.ts new file mode 100644 index 0000000..7cba406 --- /dev/null +++ b/packages/testing/src/fetch/mock-fetch.factory.ts @@ -0,0 +1,138 @@ +import { systemClock } from 'itd-api'; +import { HttpMethod } from '../http/http.constants.js'; +import type { RecordedRequest } from '../http/request.types.js'; +import { readMockRequest, recordRequest } from '../http/request-capture.utils.js'; +import type { CompiledRoute, MockRoute } from '../http/router.types.js'; +import { compileRoute, defineRoute, matchRoute } from '../http/router.utils.js'; +import { UnhandledRequestError, UnusedMockHandlersError } from '../testing.errors.js'; +import type { + CreateMockFetchOptions, + MockFetch, + MockResponder, + MockRouteOptions, +} from './mock-fetch.types.js'; +import { respond } from './responders.utils.js'; + +interface RegisteredRoute { + readonly route: MockRoute; + readonly compiled: CompiledRoute; + readonly responders: readonly MockResponder[]; + readonly repeat: boolean; + readonly optional: boolean; + calls: number; +} + +/** Создаёт сценарный `fetch`, который можно передать в `ItdClientOptions.fetch`. */ +export function createMockFetch(options: CreateMockFetchOptions = {}): MockFetch { + const clock = options.clock ?? systemClock; + const routes: RegisteredRoute[] = []; + const requests: RecordedRequest[] = []; + const unhandled: RecordedRequest[] = []; + let sequence = 0; + + const register = ( + method: string, + path: string, + input: MockResponder | readonly MockResponder[], + routeOptions: MockRouteOptions = {}, + ): MockFetch => { + const responders = Array.isArray(input) ? input : [input]; + if (responders.length === 0) + throw new TypeError(`Для ${method} ${path} не задан ни один ответ`); + const route = defineRoute(method, path); + routes.push({ + route, + compiled: compileRoute(route), + responders: [...responders], + repeat: routeOptions.repeat ?? false, + optional: routeOptions.optional ?? false, + calls: 0, + }); + return api; + }; + + const mockFetch: typeof fetch = async (input, init) => { + const request = new Request(input, init); + let selected: RegisteredRoute | undefined; + let params: Readonly> = {}; + + for (const candidate of routes) { + const available = candidate.calls < candidate.responders.length || candidate.repeat; + if (!available) continue; + const match = matchRoute(candidate.compiled, request); + if (!match) continue; + selected = candidate; + params = match.params; + break; + } + + const parsed = await readMockRequest(request, params); + const recorded = recordRequest(parsed, ++sequence, clock.now()); + requests.push(recorded); + + if (!selected) { + unhandled.push(recorded); + throw new UnhandledRequestError(request); + } + + const index = Math.min(selected.calls, selected.responders.length - 1); + selected.calls += 1; + const responder = selected.responders[index]; + if (!responder) throw new UnhandledRequestError(request); + return respond(responder, parsed); + }; + + const api: MockFetch = { + fetch: mockFetch, + get requests() { + return Object.freeze([...requests]); + }, + get unhandledRequests() { + return Object.freeze([...unhandled]); + }, + route: register, + get: (path, response, routeOptions) => register(HttpMethod.Get, path, response, routeOptions), + post: (path, response, routeOptions) => register(HttpMethod.Post, path, response, routeOptions), + put: (path, response, routeOptions) => register(HttpMethod.Put, path, response, routeOptions), + patch: (path, response, routeOptions) => + register(HttpMethod.Patch, path, response, routeOptions), + delete: (path, response, routeOptions) => + register(HttpMethod.Delete, path, response, routeOptions), + assertDone() { + api.assertNoUnhandledRequests(); + const unused = routes + .filter((item) => !item.optional && item.calls < item.responders.length) + .map( + (item) => + `${item.route.method} ${item.route.path}: осталось ${item.responders.length - item.calls}`, + ); + if (unused.length > 0) throw new UnusedMockHandlersError(unused); + }, + assertNoUnhandledRequests() { + if (unhandled.length === 0) return; + const first = unhandled[0]; + throw new UnhandledRequestError( + new Request(first?.url ?? 'https://mock.invalid', first ? { method: first.method } : {}), + ); + }, + clearRequests() { + requests.length = 0; + unhandled.length = 0; + }, + reset() { + routes.length = 0; + requests.length = 0; + unhandled.length = 0; + sequence = 0; + }, + }; + + for (const handler of options.handlers ?? []) { + register(handler.method, handler.path, handler.respond, { + ...(handler.repeat === undefined ? {} : { repeat: handler.repeat }), + ...(handler.optional === undefined ? {} : { optional: handler.optional }), + }); + } + + return api; +} diff --git a/packages/testing/src/fetch/mock-fetch.types.ts b/packages/testing/src/fetch/mock-fetch.types.ts new file mode 100644 index 0000000..eabcd05 --- /dev/null +++ b/packages/testing/src/fetch/mock-fetch.types.ts @@ -0,0 +1,65 @@ +import type { ItdClock } from 'itd-api'; +import type { RecordedRequest } from '../http/request.types.js'; +import type { MockHandler, MockRoute } from '../http/router.types.js'; + +/** Один шаг сценарного ответа. */ +export type MockResponder = Response | Error | MockHandler; + +export interface MockRouteOptions { + /** Повторять последний ответ после завершения последовательности. */ + repeat?: boolean; + /** Не считать ошибкой, если маршрут не использован. */ + optional?: boolean; +} + +export interface InitialMockRoute extends MockRoute, MockRouteOptions { + readonly respond: MockResponder | readonly MockResponder[]; +} + +/** Управляемая реализация `fetch` со сценарными ответами и историей запросов. */ +export interface MockFetch { + readonly fetch: typeof fetch; + readonly requests: readonly RecordedRequest[]; + readonly unhandledRequests: readonly RecordedRequest[]; + route( + method: string, + path: string, + respond: MockResponder | readonly MockResponder[], + options?: MockRouteOptions, + ): MockFetch; + get( + path: string, + respond: MockResponder | readonly MockResponder[], + options?: MockRouteOptions, + ): MockFetch; + post( + path: string, + respond: MockResponder | readonly MockResponder[], + options?: MockRouteOptions, + ): MockFetch; + put( + path: string, + respond: MockResponder | readonly MockResponder[], + options?: MockRouteOptions, + ): MockFetch; + patch( + path: string, + respond: MockResponder | readonly MockResponder[], + options?: MockRouteOptions, + ): MockFetch; + delete( + path: string, + respond: MockResponder | readonly MockResponder[], + options?: MockRouteOptions, + ): MockFetch; + /** Проверяет, что все конечные сценарии использованы и необработанных запросов не было. */ + assertDone(): void; + assertNoUnhandledRequests(): void; + clearRequests(): void; + reset(): void; +} + +export interface CreateMockFetchOptions { + readonly handlers?: readonly InitialMockRoute[]; + readonly clock?: ItdClock; +} diff --git a/packages/testing/src/fetch/responders.utils.ts b/packages/testing/src/fetch/responders.utils.ts new file mode 100644 index 0000000..ee83a30 --- /dev/null +++ b/packages/testing/src/fetch/responders.utils.ts @@ -0,0 +1,52 @@ +import { type ItdClock, systemClock } from 'itd-api'; +import type { MockRequest } from '../http/request.types.js'; +import type { MockHandler } from '../http/router.types.js'; +import type { MockResponder } from './mock-fetch.types.js'; + +/** Выполняет один шаг сценария и возвращает независимую копию ответа. @internal */ +export async function respond(responder: MockResponder, request: MockRequest): Promise { + if (responder instanceof Error) throw responder; + const response = responder instanceof Response ? responder : await responder(request); + return response.clone(); +} + +/** Ответ после управляемой задержки. Отмена запроса прерывает ожидание. */ +export function delayedResponse( + delay: number, + responder: MockResponder, + clock: ItdClock = systemClock, +): MockHandler { + return (request) => + new Promise((resolve, reject) => { + if (request.request.signal.aborted) { + reject(request.request.signal.reason); + return; + } + + const cancel = clock.schedule(() => { + request.request.signal.removeEventListener('abort', onAbort); + void respond(responder, request).then(resolve, reject); + }, delay); + const onAbort = () => { + cancel(); + reject( + request.request.signal.reason ?? new DOMException('Операция прервана', 'AbortError'), + ); + }; + request.request.signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** Сетевая ошибка в форме, которую обычно выдаёт `fetch`. */ +export function networkError(message = 'Тестовый сетевой сбой'): Error { + return new TypeError(message); +} + +/** Ответ, который не завершается до отмены запроса. Удобен для проверки тайм-аутов. */ +export const hangingResponse: MockHandler = (request) => + new Promise((_resolve, reject) => { + const rejectAbort = () => + reject(request.request.signal.reason ?? new DOMException('Операция прервана', 'AbortError')); + if (request.request.signal.aborted) rejectAbort(); + else request.request.signal.addEventListener('abort', rejectAbort, { once: true }); + }); diff --git a/packages/testing/src/fixtures/auth.fixtures.ts b/packages/testing/src/fixtures/auth.fixtures.ts new file mode 100644 index 0000000..8b79c12 --- /dev/null +++ b/packages/testing/src/fixtures/auth.fixtures.ts @@ -0,0 +1,38 @@ +import type { ItdSession } from 'itd-api'; +import { FIXTURE_TIME, FIXTURE_USER_ID } from './fixtures.constants.js'; +import type { AccessTokenFixtureOptions } from './fixtures.types.js'; + +/** Сессия хранилища клиента. */ +export function sessionFixture(input: Partial = {}): ItdSession { + return { + accessToken: accessTokenFixture(), + refreshToken: 'test-refresh-token', + deviceId: '00000000-0000-4000-8000-000000000099', + obtainedAt: new Date(FIXTURE_TIME).getTime(), + ...input, + }; +} + +function encodeSegment(value: unknown): string { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +/** Создаёт синтаксически корректный тестовый JWT. Токен не подписан настоящим ключом. */ +export function jwtFixture(payload: Readonly> = {}): string { + return `${encodeSegment({ alg: 'HS256', typ: 'JWT' })}.${encodeSegment(payload)}.test-signature`; +} + +/** Тестовый токен доступа с идентификаторами пользователя и сессии. */ +export function accessTokenFixture(options: AccessTokenFixtureOptions = {}): string { + const issuedAt = options.issuedAt ?? Math.floor(new Date(FIXTURE_TIME).getTime() / 1000); + return jwtFixture({ + sub: options.userId ?? FIXTURE_USER_ID, + sid: options.sessionId ?? 'test-session', + iat: issuedAt, + exp: options.expiresAt ?? issuedAt + 31_536_000, + ...options.payload, + }); +} diff --git a/packages/testing/src/fixtures/fixtures.constants.ts b/packages/testing/src/fixtures/fixtures.constants.ts new file mode 100644 index 0000000..4b94f58 --- /dev/null +++ b/packages/testing/src/fixtures/fixtures.constants.ts @@ -0,0 +1,2 @@ +export const FIXTURE_TIME = '2026-08-01T10:00:00.000Z'; +export const FIXTURE_USER_ID = '00000000-0000-4000-8000-000000000001'; diff --git a/packages/testing/src/fixtures/fixtures.types.ts b/packages/testing/src/fixtures/fixtures.types.ts new file mode 100644 index 0000000..b3d2ee3 --- /dev/null +++ b/packages/testing/src/fixtures/fixtures.types.ts @@ -0,0 +1,31 @@ +import type { + Author, + Comment, + MyProfile, + Notification, + OriginalPost, + Post, + PublicProfile, +} from 'itd-api'; + +export type AuthorFixtureInput = Partial; +export type UserFixtureInput = Partial; +export type PublicProfileFixtureInput = Partial; +export type PostFixtureInput = Partial> & { author?: AuthorFixtureInput }; +export type OriginalPostFixtureInput = Partial> & { + author?: AuthorFixtureInput; +}; +export type CommentFixtureInput = Partial> & { + author?: AuthorFixtureInput; +}; +export type NotificationFixtureInput = Partial> & { + actors?: readonly Partial[]; +}; + +export interface AccessTokenFixtureOptions { + userId?: string; + sessionId?: string; + issuedAt?: number; + expiresAt?: number; + payload?: Readonly>; +} diff --git a/packages/testing/src/fixtures.ts b/packages/testing/src/fixtures/models.fixtures.ts similarity index 64% rename from packages/testing/src/fixtures.ts rename to packages/testing/src/fixtures/models.fixtures.ts index 15183f3..aec9300 100644 --- a/packages/testing/src/fixtures.ts +++ b/packages/testing/src/fixtures/models.fixtures.ts @@ -1,30 +1,26 @@ import { type Author, type Comment, - type ItdSession, LikesVisibility, type MyProfile, type Notification, NotificationType, + type OriginalPost, type Page, type Post, type PublicProfile, WallAccess, } from 'itd-api'; - -export const FIXTURE_TIME = '2026-08-01T10:00:00.000Z'; -export const FIXTURE_USER_ID = '00000000-0000-4000-8000-000000000001'; - -export type AuthorFixtureInput = Partial; -export type UserFixtureInput = Partial; -export type PublicProfileFixtureInput = Partial; -export type PostFixtureInput = Partial> & { author?: AuthorFixtureInput }; -export type CommentFixtureInput = Partial> & { - author?: AuthorFixtureInput; -}; -export type NotificationFixtureInput = Partial> & { - actors?: readonly Partial[]; -}; +import { FIXTURE_TIME, FIXTURE_USER_ID } from './fixtures.constants.js'; +import type { + AuthorFixtureInput, + CommentFixtureInput, + NotificationFixtureInput, + OriginalPostFixtureInput, + PostFixtureInput, + PublicProfileFixtureInput, + UserFixtureInput, +} from './fixtures.types.js'; /** Автор с устойчивыми значениями по умолчанию. */ export function authorFixture(input: AuthorFixtureInput = {}): Author { @@ -111,6 +107,25 @@ export function postFixture(input: PostFixtureInput = {}): Post { }; } +/** Пост, на который ссылается репост, с устойчивыми значениями по умолчанию. */ +export function originalPostFixture(input: OriginalPostFixtureInput = {}): OriginalPost { + const { author, ...fields } = input; + return { + id: 'post-original', + content: 'Исходная запись', + spans: [], + author: authorFixture(author), + attachments: [], + likesCount: 0, + commentsCount: 0, + repostsCount: 0, + viewsCount: 0, + isDeleted: false, + createdAt: FIXTURE_TIME, + ...fields, + }; +} + /** Комментарий с устойчивыми значениями по умолчанию. */ export function commentFixture(input: CommentFixtureInput = {}): Comment { const { author, ...fields } = input; @@ -154,17 +169,6 @@ export function notificationFixture(input: NotificationFixtureInput = {}): Notif }; } -/** Сессия хранилища клиента. */ -export function sessionFixture(input: Partial = {}): ItdSession { - return { - accessToken: accessTokenFixture(), - refreshToken: 'test-refresh-token', - deviceId: '00000000-0000-4000-8000-000000000099', - obtainedAt: new Date(FIXTURE_TIME).getTime(), - ...input, - }; -} - /** Страница результата с согласованными значениями пагинации. */ export function pageFixture( items: readonly T[], @@ -172,35 +176,3 @@ export function pageFixture( ): Page { return { items: [...items], hasMore: false, raw: {}, ...input }; } - -function encodeSegment(value: unknown): string { - const bytes = new TextEncoder().encode(JSON.stringify(value)); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); -} - -/** Создаёт синтаксически корректный тестовый JWT. Токен не подписан настоящим ключом. */ -export function jwtFixture(payload: Readonly> = {}): string { - return `${encodeSegment({ alg: 'HS256', typ: 'JWT' })}.${encodeSegment(payload)}.test-signature`; -} - -export interface AccessTokenFixtureOptions { - userId?: string; - sessionId?: string; - issuedAt?: number; - expiresAt?: number; - payload?: Readonly>; -} - -/** Тестовый токен доступа с идентификаторами пользователя и сессии. */ -export function accessTokenFixture(options: AccessTokenFixtureOptions = {}): string { - const issuedAt = options.issuedAt ?? Math.floor(new Date(FIXTURE_TIME).getTime() / 1000); - return jwtFixture({ - sub: options.userId ?? FIXTURE_USER_ID, - sid: options.sessionId ?? 'test-session', - iat: issuedAt, - exp: options.expiresAt ?? issuedAt + 31_536_000, - ...options.payload, - }); -} diff --git a/packages/testing/src/constants.ts b/packages/testing/src/http/http.constants.ts similarity index 100% rename from packages/testing/src/constants.ts rename to packages/testing/src/http/http.constants.ts diff --git a/packages/testing/src/request.ts b/packages/testing/src/http/request-capture.utils.ts similarity index 77% rename from packages/testing/src/request.ts rename to packages/testing/src/http/request-capture.utils.ts index f0c25e3..1422bf9 100644 --- a/packages/testing/src/request.ts +++ b/packages/testing/src/http/request-capture.utils.ts @@ -1,36 +1,5 @@ -import { RecordedBodyType, type RecordedBodyType as RecordedBodyTypeValue } from './constants.js'; - -export type RouteParams = Readonly>; - -/** Разобранный запрос, передаваемый обработчику. В нём секреты ещё не скрыты. */ -export interface MockRequest { - readonly request: Request; - readonly method: string; - readonly url: URL; - readonly path: string; - readonly params: RouteParams; - readonly query: URLSearchParams; - readonly headers: Headers; - readonly bodyType: RecordedBodyTypeValue; - readonly body: unknown; - readonly text: string | undefined; - readonly json: unknown; - readonly formData: FormData | undefined; - readonly bytes: Uint8Array | undefined; -} - -/** Безопасная запись запроса. Секреты в заголовках, адресе и теле заменены на `[СКРЫТО]`. */ -export interface RecordedRequest { - readonly sequence: number; - readonly timestamp: number; - readonly method: string; - readonly url: string; - readonly path: string; - readonly query: Readonly>; - readonly headers: Readonly>; - readonly bodyType: RecordedBodyTypeValue; - readonly body: unknown; -} +import { RecordedBodyType } from './http.constants.js'; +import type { MockRequest, RecordedRequest, RouteParams } from './request.types.js'; const REDACTED = '[СКРЫТО]'; const SECRET_NAME = @@ -104,7 +73,7 @@ export async function readMockRequest( const clone = request.clone(); const contentType = clone.headers.get('content-type')?.toLowerCase() ?? ''; - let bodyType: RecordedBodyTypeValue = RecordedBodyType.Empty; + let bodyType: RecordedBodyType = RecordedBodyType.Empty; let body: unknown; let text: string | undefined; let json: unknown; diff --git a/packages/testing/src/http/request.types.ts b/packages/testing/src/http/request.types.ts new file mode 100644 index 0000000..76faf5f --- /dev/null +++ b/packages/testing/src/http/request.types.ts @@ -0,0 +1,33 @@ +import type { RecordedBodyType } from './http.constants.js'; + +export type RouteParams = Readonly>; + +/** Разобранный запрос, передаваемый обработчику. В нём секреты ещё не скрыты. */ +export interface MockRequest { + readonly request: Request; + readonly method: string; + readonly url: URL; + readonly path: string; + readonly params: RouteParams; + readonly query: URLSearchParams; + readonly headers: Headers; + readonly bodyType: RecordedBodyType; + readonly body: unknown; + readonly text: string | undefined; + readonly json: unknown; + readonly formData: FormData | undefined; + readonly bytes: Uint8Array | undefined; +} + +/** Безопасная запись запроса. Секреты в заголовках, адресе и теле заменены на `[СКРЫТО]`. */ +export interface RecordedRequest { + readonly sequence: number; + readonly timestamp: number; + readonly method: string; + readonly url: string; + readonly path: string; + readonly query: Readonly>; + readonly headers: Readonly>; + readonly bodyType: RecordedBodyType; + readonly body: unknown; +} diff --git a/packages/testing/src/http/responses.types.ts b/packages/testing/src/http/responses.types.ts new file mode 100644 index 0000000..f541986 --- /dev/null +++ b/packages/testing/src/http/responses.types.ts @@ -0,0 +1,6 @@ +/** Один кадр Server-Sent Events. Строка в `data` отправляется без JSON-сериализации. */ +export interface SseFrame { + event?: string; + data: unknown; + id?: string; +} diff --git a/packages/testing/src/responses.ts b/packages/testing/src/http/responses.utils.ts similarity index 93% rename from packages/testing/src/responses.ts rename to packages/testing/src/http/responses.utils.ts index 108804e..4381557 100644 --- a/packages/testing/src/responses.ts +++ b/packages/testing/src/http/responses.utils.ts @@ -1,3 +1,5 @@ +import type { SseFrame } from './responses.types.js'; + /** JSON-ответ. По умолчанию возвращает статус 200. */ export function jsonResponse(body: unknown, init: ResponseInit = {}): Response { const headers = new Headers(init.headers); @@ -37,13 +39,6 @@ export function apiErrorResponse( return jsonResponse({ error: { code, message } }, { ...init, status }); } -/** Один кадр Server-Sent Events. Строка в `data` отправляется без JSON-сериализации. */ -export interface SseFrame { - event?: string; - data: unknown; - id?: string; -} - function sseLine(name: string, value: string): string { return value .split(/\r?\n/) diff --git a/packages/testing/src/http/router.types.ts b/packages/testing/src/http/router.types.ts new file mode 100644 index 0000000..0a98733 --- /dev/null +++ b/packages/testing/src/http/router.types.ts @@ -0,0 +1,21 @@ +import type { HttpMethod } from './http.constants.js'; +import type { MockRequest, RouteParams } from './request.types.js'; + +export interface MockRoute { + readonly method: HttpMethod | string; + /** Путь с параметрами `:name`, например `/api/posts/:postId`. */ + readonly path: string; +} + +export type MockHandler = (request: MockRequest) => Response | Promise; + +/** Маршрут, разобранный в регулярное выражение. @internal */ +export interface CompiledRoute { + readonly source: MockRoute; + readonly regexp: RegExp; + readonly keys: readonly string[]; +} + +export interface RouteMatch { + readonly params: RouteParams; +} diff --git a/packages/testing/src/router.ts b/packages/testing/src/http/router.utils.ts similarity index 62% rename from packages/testing/src/router.ts rename to packages/testing/src/http/router.utils.ts index cc69d73..5ca2118 100644 --- a/packages/testing/src/router.ts +++ b/packages/testing/src/http/router.utils.ts @@ -1,19 +1,5 @@ -import type { HttpMethod } from './constants.js'; -import type { MockRequest, RouteParams } from './request.js'; - -export interface MockRoute { - readonly method: HttpMethod | string; - /** Путь с параметрами `:name`, например `/api/posts/:postId`. */ - readonly path: string; -} - -export type MockHandler = (request: MockRequest) => Response | Promise; - -interface CompiledRoute { - readonly source: MockRoute; - readonly regexp: RegExp; - readonly keys: readonly string[]; -} +import type { HttpMethod } from './http.constants.js'; +import type { CompiledRoute, MockRoute, RouteMatch } from './router.types.js'; function escapeRegex(part: string): string { return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -31,14 +17,7 @@ function compile(route: MockRoute): CompiledRoute { return { source: route, regexp: new RegExp(`^${parts.join('/')}$`), keys }; } -export interface RouteMatch { - readonly params: RouteParams; -} - -export function matchRoute( - compiled: ReturnType, - request: Request, -): RouteMatch | undefined { +export function matchRoute(compiled: CompiledRoute, request: Request): RouteMatch | undefined { if (compiled.source.method.toUpperCase() !== request.method.toUpperCase()) return undefined; const match = compiled.regexp.exec(new URL(request.url).pathname); if (!match) return undefined; @@ -54,6 +33,6 @@ export function defineRoute(method: HttpMethod | string, path: string): MockRout return Object.freeze({ method: method.toUpperCase(), path }); } -export function compileRoute(route: MockRoute): ReturnType { +export function compileRoute(route: MockRoute): CompiledRoute { return compile(route); } diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 4b1fbf9..fc6f7cf 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -4,74 +4,65 @@ * @packageDocumentation */ -export { createTestClock, type TestClock } from './clock.js'; -export { HttpMethod, RecordedBodyType } from './constants.js'; +export { createTestClock } from './clock/test-clock.factory.js'; +export type { TestClock } from './clock/test-clock.types.js'; +export type { WaitForUpdateOptions } from './events/events.types.js'; +export { waitForUpdate } from './events/events.utils.js'; +export { MockEventTransport } from './events/mock-event.transport.js'; +export { createMockFetch } from './fetch/mock-fetch.factory.js'; +export type { + CreateMockFetchOptions, + InitialMockRoute, + MockFetch, + MockResponder, + MockRouteOptions, +} from './fetch/mock-fetch.types.js'; +export { delayedResponse, hangingResponse, networkError } from './fetch/responders.utils.js'; +export { accessTokenFixture, jwtFixture, sessionFixture } from './fixtures/auth.fixtures.js'; +export { FIXTURE_TIME, FIXTURE_USER_ID } from './fixtures/fixtures.constants.js'; +export type { + AccessTokenFixtureOptions, + AuthorFixtureInput, + CommentFixtureInput, + NotificationFixtureInput, + OriginalPostFixtureInput, + PostFixtureInput, + PublicProfileFixtureInput, + UserFixtureInput, +} from './fixtures/fixtures.types.js'; export { - ItdTestingError, - MockServerSeedError, - UnhandledOperationError, - UnhandledRequestError, - UnusedMockHandlersError, - UnusedMockOperationsError, -} from './errors.js'; -export { - MockEventTransport, - type WaitForUpdateOptions, - waitForUpdate, -} from './events.js'; -export { - type AccessTokenFixtureOptions, - type AuthorFixtureInput, - accessTokenFixture, authorFixture, - type CommentFixtureInput, commentFixture, - FIXTURE_TIME, - FIXTURE_USER_ID, - jwtFixture, - type NotificationFixtureInput, notificationFixture, - type PostFixtureInput, - type PublicProfileFixtureInput, + originalPostFixture, pageFixture, postFixture, publicProfileFixture, - sessionFixture, - type UserFixtureInput, userFixture, -} from './fixtures.js'; -export { - type CreateMockFetchOptions, - createMockFetch, - delayedResponse, - hangingResponse, - type InitialMockRoute, - type MockFetch, - type MockResponder, - type MockRouteOptions, - networkError, -} from './mock-fetch.js'; -export { - type CreateMockOperationsOptions, - createMockOperations, - type InitialMockOperation, - type MockOperationHandler, - type MockOperationOptions, - type MockOperations, - type RecordedOperation, -} from './operations.js'; -export type { MockRequest, RecordedRequest, RouteParams } from './request.js'; +} from './fixtures/models.fixtures.js'; +export { HttpMethod, RecordedBodyType } from './http/http.constants.js'; +export type { MockRequest, RecordedRequest, RouteParams } from './http/request.types.js'; +export type { SseFrame } from './http/responses.types.js'; export { apiErrorResponse, apiResponse, binaryResponse, emptyResponse, jsonResponse, - type SseFrame, sseResponse, textResponse, -} from './responses.js'; -export { defineRoute, type MockHandler, type MockRoute } from './router.js'; +} from './http/responses.utils.js'; +export type { MockHandler, MockRoute } from './http/router.types.js'; +export { defineRoute } from './http/router.utils.js'; +export { createMockOperations } from './operations/mock-operations.factory.js'; +export type { + CreateMockOperationsOptions, + InitialMockOperation, + MockOperationHandler, + MockOperationOptions, + MockOperations, + RecordedOperation, +} from './operations/mock-operations.types.js'; export { type CreateMockServerOptions, createMockServer, @@ -89,4 +80,12 @@ export { type MockShopOrderSnapshot, type MockUserSeed, type MockUserSnapshot, -} from './server.js'; +} from './server/mock-server.factory.js'; +export { + ItdTestingError, + MockServerSeedError, + UnhandledOperationError, + UnhandledRequestError, + UnusedMockHandlersError, + UnusedMockOperationsError, +} from './testing.errors.js'; diff --git a/packages/testing/src/mock-fetch.ts b/packages/testing/src/mock-fetch.ts deleted file mode 100644 index 01529e3..0000000 --- a/packages/testing/src/mock-fetch.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { type ItdClock, systemClock } from 'itd-api'; -import { HttpMethod } from './constants.js'; -import { UnhandledRequestError, UnusedMockHandlersError } from './errors.js'; -import { - type MockRequest, - type RecordedRequest, - readMockRequest, - recordRequest, -} from './request.js'; -import { - compileRoute, - defineRoute, - type MockHandler, - type MockRoute, - matchRoute, -} from './router.js'; - -/** Один шаг сценарного ответа. */ -export type MockResponder = Response | Error | MockHandler; - -export interface MockRouteOptions { - /** Повторять последний ответ после завершения последовательности. */ - repeat?: boolean; - /** Не считать ошибкой, если маршрут не использован. */ - optional?: boolean; -} - -export interface InitialMockRoute extends MockRoute, MockRouteOptions { - readonly respond: MockResponder | readonly MockResponder[]; -} - -interface RegisteredRoute { - readonly route: MockRoute; - readonly compiled: ReturnType; - readonly responders: readonly MockResponder[]; - readonly repeat: boolean; - readonly optional: boolean; - calls: number; -} - -/** Управляемая реализация `fetch` со сценарными ответами и историей запросов. */ -export interface MockFetch { - readonly fetch: typeof fetch; - readonly requests: readonly RecordedRequest[]; - readonly unhandledRequests: readonly RecordedRequest[]; - route( - method: string, - path: string, - respond: MockResponder | readonly MockResponder[], - options?: MockRouteOptions, - ): MockFetch; - get( - path: string, - respond: MockResponder | readonly MockResponder[], - options?: MockRouteOptions, - ): MockFetch; - post( - path: string, - respond: MockResponder | readonly MockResponder[], - options?: MockRouteOptions, - ): MockFetch; - put( - path: string, - respond: MockResponder | readonly MockResponder[], - options?: MockRouteOptions, - ): MockFetch; - patch( - path: string, - respond: MockResponder | readonly MockResponder[], - options?: MockRouteOptions, - ): MockFetch; - delete( - path: string, - respond: MockResponder | readonly MockResponder[], - options?: MockRouteOptions, - ): MockFetch; - /** Проверяет, что все конечные сценарии использованы и необработанных запросов не было. */ - assertDone(): void; - assertNoUnhandledRequests(): void; - clearRequests(): void; - reset(): void; -} - -export interface CreateMockFetchOptions { - readonly handlers?: readonly InitialMockRoute[]; - readonly clock?: ItdClock; -} - -async function respond(responder: MockResponder, request: MockRequest): Promise { - if (responder instanceof Error) throw responder; - const response = responder instanceof Response ? responder : await responder(request); - return response.clone(); -} - -/** Создаёт сценарный `fetch`, который можно передать в `ItdClientOptions.fetch`. */ -export function createMockFetch(options: CreateMockFetchOptions = {}): MockFetch { - const clock = options.clock ?? systemClock; - const routes: RegisteredRoute[] = []; - const requests: RecordedRequest[] = []; - const unhandled: RecordedRequest[] = []; - let sequence = 0; - - const register = ( - method: string, - path: string, - input: MockResponder | readonly MockResponder[], - routeOptions: MockRouteOptions = {}, - ): MockFetch => { - const responders = Array.isArray(input) ? input : [input]; - if (responders.length === 0) - throw new TypeError(`Для ${method} ${path} не задан ни один ответ`); - const route = defineRoute(method, path); - routes.push({ - route, - compiled: compileRoute(route), - responders: [...responders], - repeat: routeOptions.repeat ?? false, - optional: routeOptions.optional ?? false, - calls: 0, - }); - return api; - }; - - const mockFetch: typeof fetch = async (input, init) => { - const request = new Request(input, init); - let selected: RegisteredRoute | undefined; - let params: Readonly> = {}; - - for (const candidate of routes) { - const available = candidate.calls < candidate.responders.length || candidate.repeat; - if (!available) continue; - const match = matchRoute(candidate.compiled, request); - if (!match) continue; - selected = candidate; - params = match.params; - break; - } - - const parsed = await readMockRequest(request, params); - const recorded = recordRequest(parsed, ++sequence, clock.now()); - requests.push(recorded); - - if (!selected) { - unhandled.push(recorded); - throw new UnhandledRequestError(request); - } - - const index = Math.min(selected.calls, selected.responders.length - 1); - selected.calls += 1; - const responder = selected.responders[index]; - if (!responder) throw new UnhandledRequestError(request); - return respond(responder, parsed); - }; - - const api: MockFetch = { - fetch: mockFetch, - get requests() { - return Object.freeze([...requests]); - }, - get unhandledRequests() { - return Object.freeze([...unhandled]); - }, - route: register, - get: (path, response, routeOptions) => register(HttpMethod.Get, path, response, routeOptions), - post: (path, response, routeOptions) => register(HttpMethod.Post, path, response, routeOptions), - put: (path, response, routeOptions) => register(HttpMethod.Put, path, response, routeOptions), - patch: (path, response, routeOptions) => - register(HttpMethod.Patch, path, response, routeOptions), - delete: (path, response, routeOptions) => - register(HttpMethod.Delete, path, response, routeOptions), - assertDone() { - api.assertNoUnhandledRequests(); - const unused = routes - .filter((item) => !item.optional && item.calls < item.responders.length) - .map( - (item) => - `${item.route.method} ${item.route.path}: осталось ${item.responders.length - item.calls}`, - ); - if (unused.length > 0) throw new UnusedMockHandlersError(unused); - }, - assertNoUnhandledRequests() { - if (unhandled.length === 0) return; - const first = unhandled[0]; - throw new UnhandledRequestError( - new Request(first?.url ?? 'https://mock.invalid', first ? { method: first.method } : {}), - ); - }, - clearRequests() { - requests.length = 0; - unhandled.length = 0; - }, - reset() { - routes.length = 0; - requests.length = 0; - unhandled.length = 0; - sequence = 0; - }, - }; - - for (const handler of options.handlers ?? []) { - register(handler.method, handler.path, handler.respond, { - ...(handler.repeat === undefined ? {} : { repeat: handler.repeat }), - ...(handler.optional === undefined ? {} : { optional: handler.optional }), - }); - } - - return api; -} - -/** Ответ после управляемой задержки. Отмена запроса прерывает ожидание. */ -export function delayedResponse( - delay: number, - responder: MockResponder, - clock: ItdClock = systemClock, -): MockHandler { - return (request) => - new Promise((resolve, reject) => { - if (request.request.signal.aborted) { - reject(request.request.signal.reason); - return; - } - - const cancel = clock.schedule(() => { - request.request.signal.removeEventListener('abort', onAbort); - void respond(responder, request).then(resolve, reject); - }, delay); - const onAbort = () => { - cancel(); - reject( - request.request.signal.reason ?? new DOMException('Операция прервана', 'AbortError'), - ); - }; - request.request.signal.addEventListener('abort', onAbort, { once: true }); - }); -} - -/** Сетевая ошибка в форме, которую обычно выдаёт `fetch`. */ -export function networkError(message = 'Тестовый сетевой сбой'): Error { - return new TypeError(message); -} - -/** Ответ, который не завершается до отмены запроса. Удобен для проверки тайм-аутов. */ -export const hangingResponse: MockHandler = (request) => - new Promise((_resolve, reject) => { - const rejectAbort = () => - reject(request.request.signal.reason ?? new DOMException('Операция прервана', 'AbortError')); - if (request.request.signal.aborted) rejectAbort(); - else request.request.signal.addEventListener('abort', rejectAbort, { once: true }); - }); diff --git a/packages/testing/src/operations.ts b/packages/testing/src/operations/mock-operations.factory.ts similarity index 53% rename from packages/testing/src/operations.ts rename to packages/testing/src/operations/mock-operations.factory.ts index db86adc..ae280fe 100644 --- a/packages/testing/src/operations.ts +++ b/packages/testing/src/operations/mock-operations.factory.ts @@ -1,37 +1,12 @@ +import type { OperationId, OperationRequestOptions, OperationTransformer } from 'itd-api'; +import { UnhandledOperationError, UnusedMockOperationsError } from '../testing.errors.js'; import type { - ClientPlugin, - OperationId, - OperationRequestOptions, - OperationTransformer, -} from 'itd-api'; -import { UnhandledOperationError, UnusedMockOperationsError } from './errors.js'; - -/** Обработчик логической операции; возвращает уже разобранный результат метода SDK. */ -export type MockOperationHandler = ( - request: Readonly, -) => unknown | Promise; - -/** Настройки одного сценария операции. */ -export interface MockOperationOptions { - /** Повторять последний ответ после завершения последовательности. */ - repeat?: boolean; - /** Не считать ошибкой неиспользованный сценарий. */ - optional?: boolean; -} - -/** Операция, зарегистрированная при создании mock-плагина. */ -export interface InitialMockOperation extends MockOperationOptions { - readonly operationId: OperationId; - /** Значение, ошибка или функция, возвращающая разобранный результат. */ - readonly respond: unknown | MockOperationHandler; -} - -/** Зафиксированный вызов семантической операции. */ -export interface RecordedOperation { - readonly sequence: number; - readonly operationId: OperationId; - readonly request: Readonly; -} + CreateMockOperationsOptions, + MockOperationHandler, + MockOperationOptions, + MockOperations, + RecordedOperation, +} from './mock-operations.types.js'; interface RegisteredOperation { readonly operationId: OperationId; @@ -41,45 +16,6 @@ interface RegisteredOperation { calls: number; } -export interface CreateMockOperationsOptions { - readonly handlers?: readonly InitialMockOperation[]; - /** Имя плагина, если одному клиенту нужны несколько независимых наборов. */ - readonly name?: string; - /** Передавать незарегистрированные операции дальше. По умолчанию они завершаются ошибкой. */ - readonly passthrough?: boolean; -} - -/** - * Operation-level mock одновременно является плагином для `itd.use(mock)`. - * - * Он работает выше retry, auth recovery, очереди и транспорта: один обработчик соответствует - * одному вызову метода SDK и возвращает готовый разобранный результат без HTTP-обёртки. - */ -export interface MockOperations extends ClientPlugin { - readonly calls: readonly RecordedOperation[]; - readonly unhandledCalls: readonly RecordedOperation[]; - operation( - operationId: OperationId, - respond: MockOperationHandler, - options?: MockOperationOptions, - ): MockOperations; - operation( - operationId: OperationId, - respond: T extends (...args: never[]) => unknown ? never : T, - options?: MockOperationOptions, - ): MockOperations; - sequence( - operationId: OperationId, - responders: readonly unknown[], - options?: MockOperationOptions, - ): MockOperations; - /** Проверяет, что все обязательные ответы использованы и не было неизвестных операций. */ - assertDone(): void; - assertNoUnhandledOperations(): void; - clearCalls(): void; - reset(): void; -} - async function respond(responder: unknown, request: OperationRequestOptions): Promise { if (responder instanceof Error) throw responder; return typeof responder === 'function' ? (responder as MockOperationHandler)(request) : responder; diff --git a/packages/testing/src/operations/mock-operations.types.ts b/packages/testing/src/operations/mock-operations.types.ts new file mode 100644 index 0000000..9c699b4 --- /dev/null +++ b/packages/testing/src/operations/mock-operations.types.ts @@ -0,0 +1,67 @@ +import type { ClientPlugin, OperationId, OperationRequestOptions } from 'itd-api'; + +/** Обработчик логической операции; возвращает уже разобранный результат метода SDK. */ +export type MockOperationHandler = ( + request: Readonly, +) => unknown | Promise; + +/** Настройки одного сценария операции. */ +export interface MockOperationOptions { + /** Повторять последний ответ после завершения последовательности. */ + repeat?: boolean; + /** Не считать ошибкой неиспользованный сценарий. */ + optional?: boolean; +} + +/** Операция, зарегистрированная при создании mock-плагина. */ +export interface InitialMockOperation extends MockOperationOptions { + readonly operationId: OperationId; + /** Значение, ошибка или функция, возвращающая разобранный результат. */ + readonly respond: unknown | MockOperationHandler; +} + +/** Зафиксированный вызов семантической операции. */ +export interface RecordedOperation { + readonly sequence: number; + readonly operationId: OperationId; + readonly request: Readonly; +} + +export interface CreateMockOperationsOptions { + readonly handlers?: readonly InitialMockOperation[]; + /** Имя плагина, если одному клиенту нужны несколько независимых наборов. */ + readonly name?: string; + /** Передавать незарегистрированные операции дальше. По умолчанию они завершаются ошибкой. */ + readonly passthrough?: boolean; +} + +/** + * Operation-level mock одновременно является плагином для `itd.use(mock)`. + * + * Он работает выше retry, auth recovery, очереди и транспорта: один обработчик соответствует + * одному вызову метода SDK и возвращает готовый разобранный результат без HTTP-обёртки. + */ +export interface MockOperations extends ClientPlugin { + readonly calls: readonly RecordedOperation[]; + readonly unhandledCalls: readonly RecordedOperation[]; + operation( + operationId: OperationId, + respond: MockOperationHandler, + options?: MockOperationOptions, + ): MockOperations; + operation( + operationId: OperationId, + respond: T extends (...args: never[]) => unknown ? never : T, + options?: MockOperationOptions, + ): MockOperations; + sequence( + operationId: OperationId, + responders: readonly unknown[], + options?: MockOperationOptions, + ): MockOperations; + /** Проверяет, что все обязательные ответы использованы и не было неизвестных операций. */ + assertDone(): void; + assertNoUnhandledOperations(): void; + clearCalls(): void; + reset(): void; +} diff --git a/packages/testing/src/server/comments/comments.presenter.ts b/packages/testing/src/server/comments/comments.presenter.ts new file mode 100644 index 0000000..2ba1ef5 --- /dev/null +++ b/packages/testing/src/server/comments/comments.presenter.ts @@ -0,0 +1,63 @@ +import type { Comment, CommentUpdateResult, LikeResult } from 'itd-api'; +import { commentFixture } from '../../fixtures/models.fixtures.js'; +import type { CursorSlice, NumberedSlice } from '../shared/pagination.types.js'; +import { numberedPagination } from '../shared/pagination.utils.js'; +import type { UserPresenter } from '../users/users.presenter.js'; +import type { UserService } from '../users/users.service.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { CommentService } from './comments.service.js'; +import type { CommentPage, CommentRecord, ReplyPage } from './comments.types.js'; + +/** Комментарии в форме ответов API относительно текущего пользователя. @internal */ +export class CommentPresenter { + readonly #users: UserService; + readonly #comments: CommentService; + readonly #userPresenter: UserPresenter; + + constructor(users: UserService, comments: CommentService, userPresenter: UserPresenter) { + this.#users = users; + this.#comments = comments; + this.#userPresenter = userPresenter; + } + + /** Страница комментариев первого уровня; `total` — число всех комментариев первого уровня. */ + page(slice: CursorSlice, viewer: UserRecord, total: number): CommentPage { + return { + comments: slice.items.map((comment) => this.comment(comment, viewer)), + hasMore: slice.hasMore, + nextCursor: slice.nextCursor, + total, + }; + } + + replyPage(slice: NumberedSlice, viewer: UserRecord): ReplyPage { + return { + replies: slice.items.map((comment) => this.comment(comment, viewer)), + pagination: numberedPagination(slice), + }; + } + + updateResult(comment: CommentRecord, editedAt: string): CommentUpdateResult { + return { id: comment.id, content: comment.content, editedAt }; + } + + likeResult(comment: CommentRecord, liked: boolean): LikeResult { + return { liked, likesCount: comment.likedBy.size }; + } + + comment(comment: CommentRecord, viewer: UserRecord): Comment { + const author = this.#users.get(comment.authorId); + if (!author) throw new Error(`У комментария ${comment.id} нет автора ${comment.authorId}`); + const replyTo = comment.replyToUserId ? this.#users.get(comment.replyToUserId) : undefined; + return commentFixture({ + id: comment.id, + content: comment.content, + author: this.#userPresenter.author(author), + likesCount: comment.likedBy.size, + repliesCount: this.#comments.activeReplyCount(comment), + isLiked: comment.likedBy.has(viewer.profile.id), + createdAt: comment.createdAt, + ...(replyTo ? { replyTo: this.#userPresenter.replyTo(replyTo) } : {}), + }); + } +} diff --git a/packages/testing/src/server/comments/comments.routes.ts b/packages/testing/src/server/comments/comments.routes.ts new file mode 100644 index 0000000..2181f8d --- /dev/null +++ b/packages/testing/src/server/comments/comments.routes.ts @@ -0,0 +1,103 @@ +import { HttpMethod } from '../../http/http.constants.js'; +import { apiResponse, emptyResponse } from '../../http/responses.utils.js'; +import type { MockHandler } from '../../http/router.types.js'; +import { cursorPage, numberedPage } from '../shared/pagination.utils.js'; +import { cursorQuery, numberedQuery, objectBody, stringField } from '../shared/request.utils.js'; +import type { MockRouteContext } from '../shared/route.types.js'; + +export function registerCommentRoutes({ + services, + presenters, + route, + requireAuth, +}: MockRouteContext): void { + const { comments, posts } = services; + + route( + HttpMethod.Get, + '/api/posts/:postId/comments', + requireAuth((request, viewer) => { + const all = comments.topLevel(posts.requireActive(request.params.postId ?? '')); + const page = cursorPage(all, cursorQuery(request)); + return apiResponse(presenters.comments.page(page, viewer, all.length)); + }), + ); + + route( + HttpMethod.Post, + '/api/posts/:postId/comments', + requireAuth((request, user) => { + const post = posts.requireActive(request.params.postId ?? ''); + const comment = comments.create( + post, + user, + stringField(objectBody(request), 'content') ?? '', + ); + return apiResponse(presenters.comments.comment(comment, user), { status: 201 }); + }), + ); + + route( + HttpMethod.Get, + '/api/comments/:commentId/replies', + requireAuth((request, viewer) => { + const parent = comments.requireActive(request.params.commentId ?? ''); + const page = numberedPage(comments.replies(parent), numberedQuery(request)); + return apiResponse(presenters.comments.replyPage(page, viewer)); + }), + ); + + route( + HttpMethod.Post, + '/api/comments/:commentId/replies', + requireAuth((request, user) => { + const parent = comments.requireActive(request.params.commentId ?? ''); + const body = objectBody(request); + const reply = comments.reply( + parent, + user, + stringField(body, 'content') ?? '', + stringField(body, 'replyToUserId'), + ); + return apiResponse(presenters.comments.comment(reply, user), { status: 201 }); + }), + ); + + route( + HttpMethod.Patch, + '/api/comments/:commentId', + requireAuth((request, user) => { + const comment = comments.requireOwn(request.params.commentId ?? '', user); + const editedAt = comments.edit(comment, stringField(objectBody(request), 'content')); + return apiResponse(presenters.comments.updateResult(comment, editedAt)); + }), + ); + + route( + HttpMethod.Delete, + '/api/comments/:commentId', + requireAuth((request, user) => { + comments.remove(comments.requireOwn(request.params.commentId ?? '', user)); + return emptyResponse(); + }), + ); + + route( + HttpMethod.Post, + '/api/comments/:commentId/restore', + requireAuth((request, user) => { + comments.restore(comments.requireOwn(request.params.commentId ?? '', user)); + return emptyResponse(); + }), + ); + + const reaction = (liked: boolean): MockHandler => + requireAuth((request, user) => { + const comment = comments.requireActive(request.params.commentId ?? ''); + if (liked) comments.like(user, comment); + else comments.unlike(user, comment); + return apiResponse(presenters.comments.likeResult(comment, liked)); + }); + route(HttpMethod.Post, '/api/comments/:commentId/like', reaction(true)); + route(HttpMethod.Delete, '/api/comments/:commentId/like', reaction(false)); +} diff --git a/packages/testing/src/server/comments/comments.service.ts b/packages/testing/src/server/comments/comments.service.ts new file mode 100644 index 0000000..bdcece1 --- /dev/null +++ b/packages/testing/src/server/comments/comments.service.ts @@ -0,0 +1,172 @@ +import { NotificationType } from 'itd-api'; +import type { NotificationService } from '../notifications/notifications.service.js'; +import type { PostRecord } from '../posts/posts.types.js'; +import { MockDomainError } from '../shared/domain.errors.js'; +import type { MockStore } from '../store/mock.store.js'; +import type { UserService } from '../users/users.service.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { CommentRecord } from './comments.types.js'; + +function oldestFirst(a: CommentRecord, b: CommentRecord): number { + return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); +} + +/** Комментарии, ответы и реакции на них. @internal */ +export class CommentService { + readonly #store: MockStore; + readonly #users: UserService; + readonly #notifications: NotificationService; + + constructor(store: MockStore, users: UserService, notifications: NotificationService) { + this.#store = store; + this.#users = users; + this.#notifications = notifications; + } + + /** Активные комментарии первого уровня, сначала старые. */ + topLevel(post: PostRecord): CommentRecord[] { + return this.#active( + (comment) => comment.postId === post.id && comment.parentCommentId === null, + ); + } + + /** Активные ответы на комментарий, сначала старые. */ + replies(parent: CommentRecord): CommentRecord[] { + return this.#active((comment) => comment.parentCommentId === parent.id); + } + + activeCountFor(post: PostRecord): number { + return this.#count((comment) => comment.postId === post.id); + } + + activeReplyCount(parent: CommentRecord): number { + return this.#count((comment) => comment.parentCommentId === parent.id); + } + + /** Видимый комментарий: не удалён, автор не деактивирован. */ + requireActive(commentId: string): CommentRecord { + const comment = this.#store.comments.get(commentId); + if (!comment || !this.#visible(comment)) throw MockDomainError.notFound('Comment not found'); + return comment; + } + + /** Комментарий автора, в том числе удалённый: автор может его восстановить. */ + requireOwn(commentId: string, author: UserRecord): CommentRecord { + const comment = this.#store.comments.get(commentId); + if (!comment) throw MockDomainError.notFound('Comment not found'); + if (comment.authorId !== author.profile.id) { + throw MockDomainError.forbidden('Комментарий принадлежит другому пользователю'); + } + return comment; + } + + create(post: PostRecord, author: UserRecord, content: string): CommentRecord { + const comment = this.#insert({ + postId: post.id, + authorId: author.profile.id, + parentCommentId: null, + replyToUserId: undefined, + content, + }); + this.#notifications.notify({ + recipientId: post.authorId, + type: NotificationType.PostComment, + actorId: author.profile.id, + entityId: comment.id, + parentEntityId: post.id, + preview: comment.content, + }); + return comment; + } + + /** + * Отвечает на комментарий. Ветки двухуровневые: ответ на ответ встаёт в ветку комментария + * первого уровня. Без явного адресата ответ адресован автору комментария, на который отвечают. + */ + reply( + parent: CommentRecord, + author: UserRecord, + content: string, + replyToUserId: string = parent.authorId, + ): CommentRecord { + if (!this.#users.isActive(replyToUserId)) throw MockDomainError.notFound('User not found'); + const reply = this.#insert({ + postId: parent.postId, + authorId: author.profile.id, + parentCommentId: parent.parentCommentId ?? parent.id, + replyToUserId, + content, + }); + this.#notifications.notify({ + recipientId: replyToUserId, + type: NotificationType.CommentReply, + actorId: author.profile.id, + entityId: reply.id, + parentEntityId: parent.postId, + preview: reply.content, + }); + return reply; + } + + /** Возвращает время изменения. */ + edit(comment: CommentRecord, content: string | undefined): string { + if (content !== undefined) comment.content = content; + return this.#store.now(); + } + + remove(comment: CommentRecord): void { + comment.deleted = true; + } + + restore(comment: CommentRecord): void { + comment.deleted = false; + } + + /** Ставит реакцию. Уведомление автор получает только о новой реакции. */ + like(user: UserRecord, comment: CommentRecord): void { + if (comment.likedBy.has(user.profile.id)) return; + comment.likedBy.add(user.profile.id); + this.#notifications.notify({ + recipientId: comment.authorId, + type: NotificationType.CommentReaction, + actorId: user.profile.id, + entityId: comment.id, + parentEntityId: comment.postId, + preview: comment.content, + }); + } + + unlike(user: UserRecord, comment: CommentRecord): void { + comment.likedBy.delete(user.profile.id); + } + + #insert(fields: Omit): CommentRecord { + const comment: CommentRecord = { + id: this.#store.nextCommentId(), + ...fields, + createdAt: this.#store.now(), + likedBy: new Set(), + deleted: false, + }; + this.#store.comments.set(comment.id, comment); + return comment; + } + + #active(predicate: (comment: CommentRecord) => boolean): CommentRecord[] { + return [...this.#store.comments.values()] + .filter((comment) => this.#visible(comment) && predicate(comment)) + .sort(oldestFirst); + } + + #count(predicate: (comment: CommentRecord) => boolean): number { + let count = 0; + for (const comment of this.#store.comments.values()) { + if (this.#visible(comment) && predicate(comment)) count += 1; + } + return count; + } + + #visible(comment: CommentRecord): boolean { + return !comment.deleted && this.#users.isActive(comment.authorId); + } +} diff --git a/packages/testing/src/server/comments/comments.types.ts b/packages/testing/src/server/comments/comments.types.ts new file mode 100644 index 0000000..8e5a773 --- /dev/null +++ b/packages/testing/src/server/comments/comments.types.ts @@ -0,0 +1,29 @@ +import type { Comment } from 'itd-api'; +import type { NumberedPagination } from '../shared/pagination.types.js'; + +/** Комментарий или ответ в хранилище mock-server. @internal */ +export interface CommentRecord { + id: string; + postId: string; + authorId: string; + parentCommentId: string | null; + replyToUserId: string | undefined; + content: string; + createdAt: string; + likedBy: Set; + deleted: boolean; +} + +/** Страница комментариев первого уровня в форме ответа API. @internal */ +export interface CommentPage { + comments: Comment[]; + hasMore: boolean; + nextCursor: string | null; + total: number; +} + +/** Страница ответов на комментарий в форме ответа API. @internal */ +export interface ReplyPage { + replies: Comment[]; + pagination: NumberedPagination; +} diff --git a/packages/testing/src/server/entities.ts b/packages/testing/src/server/entities.ts deleted file mode 100644 index c5c7192..0000000 --- a/packages/testing/src/server/entities.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { MyProfile, Notification, ShopOrder, ShopProduct } from 'itd-api'; - -/** Пользователь во внутреннем состоянии mock-server. @internal */ -export interface UserState { - profile: MyProfile; - following: Set; - deactivated: boolean; -} - -/** Пост во внутреннем состоянии mock-server. @internal */ -export interface PostState { - id: string; - authorId: string; - content: string; - wallRecipientId: string | null; - createdAt: string; - editedAt: string | null; - likedBy: Set; - deleted: boolean; -} - -/** Комментарий во внутреннем состоянии mock-server. @internal */ -export interface CommentState { - id: string; - postId: string; - authorId: string; - parentCommentId: string | null; - replyToUserId: string | undefined; - content: string; - createdAt: string; - likedBy: Set; - deleted: boolean; -} - -/** Уведомление и его получатель во внутреннем состоянии mock-server. @internal */ -export interface NotificationState { - userId: string; - value: Notification; -} - -/** Заказ магазина во внутреннем состоянии mock-сервера. @internal */ -export interface ShopOrderState { - value: ShopOrder; - userId?: string; - email: string; - accessToken?: string; -} - -/** Полностью собранное состояние, которым можно атомарно заменить данные сервера. @internal */ -export interface BuiltMockServerSeed { - users: Map; - posts: Map; - comments: Map; - notifications: NotificationState[]; - shopProducts: Map; - shopOrders: Map; - postSequence: number; - commentSequence: number; - notificationSequence: number; - shopOrderSequence: number; -} diff --git a/packages/testing/src/server/hashtags/hashtags.presenter.ts b/packages/testing/src/server/hashtags/hashtags.presenter.ts new file mode 100644 index 0000000..2c4a591 --- /dev/null +++ b/packages/testing/src/server/hashtags/hashtags.presenter.ts @@ -0,0 +1,23 @@ +import type { Hashtag } from 'itd-api'; +import type { PostPresenter } from '../posts/posts.presenter.js'; +import type { PostRecord } from '../posts/posts.types.js'; +import type { CursorSlice } from '../shared/pagination.types.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { HashtagPostsPage } from './hashtags.types.js'; + +/** Ответы хэштегов в форме API. @internal */ +export class HashtagPresenter { + readonly #posts: PostPresenter; + + constructor(posts: PostPresenter) { + this.#posts = posts; + } + + postsPage( + hashtag: Hashtag | undefined, + slice: CursorSlice, + viewer: UserRecord, + ): HashtagPostsPage { + return { hashtag: hashtag ?? null, ...this.#posts.page(slice, viewer) }; + } +} diff --git a/packages/testing/src/server/hashtags/hashtags.routes.ts b/packages/testing/src/server/hashtags/hashtags.routes.ts new file mode 100644 index 0000000..339d3a0 --- /dev/null +++ b/packages/testing/src/server/hashtags/hashtags.routes.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '../../http/http.constants.js'; +import { apiResponse } from '../../http/responses.utils.js'; +import { positiveInt } from '../shared/numbers.utils.js'; +import { cursorPage } from '../shared/pagination.utils.js'; +import { cursorQuery } from '../shared/request.utils.js'; +import type { MockRouteContext } from '../shared/route.types.js'; + +const TRENDING_LIMIT = 10; + +export function registerHashtagRoutes({ + services, + presenters, + route, + requireAuth, +}: MockRouteContext): void { + const { hashtags } = services; + + route(HttpMethod.Get, '/api/hashtags/trending', (request) => + apiResponse({ + hashtags: hashtags.trending(positiveInt(request.query.get('limit'), TRENDING_LIMIT)), + }), + ); + + route( + HttpMethod.Get, + '/api/hashtags/:tag/posts', + requireAuth((request, viewer) => { + const tag = request.params.tag ?? ''; + const page = cursorPage(hashtags.posts(tag), cursorQuery(request)); + return apiResponse(presenters.hashtags.postsPage(hashtags.find(tag), page, viewer)); + }), + ); +} diff --git a/packages/testing/src/server/hashtags/hashtags.service.ts b/packages/testing/src/server/hashtags/hashtags.service.ts new file mode 100644 index 0000000..911e71d --- /dev/null +++ b/packages/testing/src/server/hashtags/hashtags.service.ts @@ -0,0 +1,72 @@ +import type { Hashtag } from 'itd-api'; +import type { PostService } from '../posts/posts.service.js'; +import type { PostRecord } from '../posts/posts.types.js'; +import { hashtagKey, hashtagNames } from './hashtags.utils.js'; + +/** Сводка по хэштегу: сколько активных постов и когда появился последний. */ +interface HashtagUsage { + hashtag: Hashtag; + latestPostAt: string; +} + +function byName(a: Hashtag, b: Hashtag): number { + return a.name.localeCompare(b.name); +} + +/** Хэштеги, вычисляемые из разметки активных постов. Отдельно они не хранятся. @internal */ +export class HashtagService { + readonly #posts: PostService; + + constructor(posts: PostService) { + this.#posts = posts; + } + + /** Хэштег по имени без учёта регистра и решётки. */ + find(name: string): Hashtag | undefined { + return this.#usage().get(hashtagKey(name))?.hashtag; + } + + /** Активные посты с хэштегом, сначала новые. */ + posts(name: string): PostRecord[] { + const key = hashtagKey(name); + return this.#posts.feed().filter((post) => hashtagNames(post).includes(key)); + } + + /** Хэштеги свежих постов: сначала те, чей последний пост новее. */ + trending(limit: number): Hashtag[] { + return [...this.#usage().values()] + .sort((a, b) => b.latestPostAt.localeCompare(a.latestPostAt) || byName(a.hashtag, b.hashtag)) + .slice(0, limit) + .map((usage) => usage.hashtag); + } + + /** Хэштеги, имя которых начинается с запроса, самые популярные первыми. */ + searchByPrefix(query: string, limit: number): Hashtag[] { + const prefix = hashtagKey(query); + if (!prefix) return []; + return [...this.#usage().values()] + .map((usage) => usage.hashtag) + .filter((hashtag) => hashtag.name.startsWith(prefix)) + .sort((a, b) => b.postsCount - a.postsCount || byName(a, b)) + .slice(0, limit); + } + + #usage(): Map { + const usage = new Map(); + for (const post of this.#posts.feed()) { + for (const name of hashtagNames(post)) { + const current = usage.get(name); + if (current) { + current.hashtag.postsCount += 1; + if (post.createdAt > current.latestPostAt) current.latestPostAt = post.createdAt; + } else { + usage.set(name, { + hashtag: { id: `hashtag-${name}`, name, postsCount: 1 }, + latestPostAt: post.createdAt, + }); + } + } + } + return usage; + } +} diff --git a/packages/testing/src/server/hashtags/hashtags.types.ts b/packages/testing/src/server/hashtags/hashtags.types.ts new file mode 100644 index 0000000..161bcbb --- /dev/null +++ b/packages/testing/src/server/hashtags/hashtags.types.ts @@ -0,0 +1,7 @@ +import type { Hashtag } from 'itd-api'; +import type { PostPage } from '../posts/posts.types.js'; + +/** Страница постов по хэштегу в форме ответа API. @internal */ +export interface HashtagPostsPage extends PostPage { + hashtag: Hashtag | null; +} diff --git a/packages/testing/src/server/hashtags/hashtags.utils.ts b/packages/testing/src/server/hashtags/hashtags.utils.ts new file mode 100644 index 0000000..51b0d45 --- /dev/null +++ b/packages/testing/src/server/hashtags/hashtags.utils.ts @@ -0,0 +1,21 @@ +import { SpanType } from 'itd-api'; +import type { PostRecord } from '../posts/posts.types.js'; + +/** Ключ хэштега: без решётки и без учёта регистра. @internal */ +export function hashtagKey(name: string): string { + return name.trim().replace(/^#/, '').toLowerCase(); +} + +/** + * Хэштеги поста по его разметке, каждый один раз. Имя берётся из `tag`, а без него — из + * размеченного фрагмента текста. @internal + */ +export function hashtagNames(post: Pick): string[] { + const names = new Set(); + for (const span of post.spans) { + if (span.type !== SpanType.Hashtag) continue; + const name = hashtagKey(span.tag ?? post.content.slice(span.offset, span.offset + span.length)); + if (name) names.add(name); + } + return [...names]; +} diff --git a/packages/testing/src/server.ts b/packages/testing/src/server/mock-server.factory.ts similarity index 65% rename from packages/testing/src/server.ts rename to packages/testing/src/server/mock-server.factory.ts index fcca3ee..d8b36c0 100644 --- a/packages/testing/src/server.ts +++ b/packages/testing/src/server/mock-server.factory.ts @@ -1,16 +1,24 @@ import { systemClock } from 'itd-api'; -import { accessTokenFixture } from './fixtures.js'; -import { type RecordedRequest, readMockRequest, recordRequest } from './request.js'; -import { apiErrorResponse } from './responses.js'; -import { compileRoute, defineRoute, type MockHandler, matchRoute } from './router.js'; -import type { CreateMockServerOptions, MockServer, MockServerSeed } from './server/contracts.js'; -import { registerCommentRoutes } from './server/routes/comments.js'; -import { createRouteContext, type RegisteredHandler } from './server/routes/context.js'; -import { registerNotificationRoutes } from './server/routes/notifications.js'; -import { registerPostRoutes } from './server/routes/posts.js'; -import { registerShopRoutes } from './server/routes/shop.js'; -import { registerUserRoutes } from './server/routes/users.js'; -import { MockServerState } from './server/state.js'; +import { accessTokenFixture } from '../fixtures/auth.fixtures.js'; +import type { RecordedRequest } from '../http/request.types.js'; +import { readMockRequest, recordRequest } from '../http/request-capture.utils.js'; +import { apiErrorResponse } from '../http/responses.utils.js'; +import type { MockHandler } from '../http/router.types.js'; +import { compileRoute, defineRoute, matchRoute } from '../http/router.utils.js'; +import { ItdTestingError } from '../testing.errors.js'; +import { registerCommentRoutes } from './comments/comments.routes.js'; +import { registerHashtagRoutes } from './hashtags/hashtags.routes.js'; +import { registerNotificationRoutes } from './notifications/notifications.routes.js'; +import { registerPostRoutes } from './posts/posts.routes.js'; +import { registerSearchRoutes } from './search/search.routes.js'; +import { MockServerRuntime } from './server.runtime.js'; +import type { CreateMockServerOptions, MockServer, MockServerSeed } from './server.types.js'; +import { MockErrorCode } from './shared/domain.constants.js'; +import { createRouteContext } from './shared/route.context.js'; +import type { RegisteredHandler } from './shared/route.types.js'; +import { TokenAuthenticator } from './shared/token.authenticator.js'; +import { registerShopRoutes } from './shop/shop.routes.js'; +import { registerUserRoutes } from './users/users.routes.js'; export type { CreateMockServerOptions, @@ -28,13 +36,13 @@ export type { MockShopOrderSnapshot, MockUserSeed, MockUserSnapshot, -} from './server/contracts.js'; +} from './server.types.js'; /** Создаёт сервер API в памяти. Он принимает обычный `fetch`, но не открывает порт. */ export function createMockServer(options: CreateMockServerOptions = {}): MockServer { const clock = options.clock ?? systemClock; const baseUrl = (options.baseUrl ?? 'https://mock.itd.test').replace(/\/$/, ''); - const state = new MockServerState(clock); + const runtime = new MockServerRuntime(clock); const routes: RegisteredHandler[] = []; const overrides: RegisteredHandler[] = []; const failures: RegisteredHandler[] = []; @@ -43,11 +51,20 @@ export function createMockServer(options: CreateMockServerOptions = {}): MockSer let initialSeed: MockServerSeed | undefined = options.seed; let requestSequence = 0; - const context = createRouteContext(state, routes); + const context = createRouteContext( + { + services: runtime.services, + presenters: runtime.presenters, + auth: new TokenAuthenticator(runtime.services.users), + }, + routes, + ); registerUserRoutes(context); registerPostRoutes(context); registerCommentRoutes(context); registerNotificationRoutes(context); + registerHashtagRoutes(context); + registerSearchRoutes(context); registerShopRoutes(context); const dispatch = async ( @@ -77,7 +94,7 @@ export function createMockServer(options: CreateMockServerOptions = {}): MockSer unsupportedRequests.push(recorded); return apiErrorResponse( 501, - 'MOCK_ROUTE_NOT_IMPLEMENTED', + MockErrorCode.RouteNotImplemented, `Mock server не реализует ${request.method} ${new URL(request.url).pathname}`, ); }; @@ -94,7 +111,15 @@ export function createMockServer(options: CreateMockServerOptions = {}): MockSer return registered; }; - state.loadSeed(initialSeed); + const requireUser = (reference: string) => { + const user = runtime.services.users.find(reference); + if (!user) { + throw new ItdTestingError(`Нет пользователя с id или текущим username ${reference}`); + } + return user; + }; + + runtime.load(initialSeed); return { fetch: fetchImpl, @@ -105,8 +130,7 @@ export function createMockServer(options: CreateMockServerOptions = {}): MockSer return Object.freeze([...unsupportedRequests]); }, clientOptions({ as }) { - const user = state.findUser(as); - if (!user) throw new Error(`В seed нет пользователя ${as}`); + const user = requireUser(as); return { baseUrl, fetch: fetchImpl, @@ -121,10 +145,10 @@ export function createMockServer(options: CreateMockServerOptions = {}): MockSer }; }, snapshot() { - return state.snapshot(); + return runtime.snapshot(); }, reset(seed = initialSeed) { - state.loadSeed(seed); + runtime.load(seed); initialSeed = seed; requests.length = 0; unsupportedRequests.length = 0; @@ -145,14 +169,13 @@ export function createMockServer(options: CreateMockServerOptions = {}): MockSer }; }, notificationEvents({ as }) { - const user = state.findUser(as); - if (!user) throw new Error(`В seed нет пользователя ${as}`); - return state.registerEventTransport(user); + const user = requireUser(as); + return runtime.delivery.connect(user); }, assertNoUnsupportedRequests() { if (unsupportedRequests.length > 0) { const first = unsupportedRequests[0]; - throw new Error(`Mock server не реализует ${first?.method} ${first?.path}`); + throw new ItdTestingError(`Mock server не реализует ${first?.method} ${first?.path}`); } }, clearRequests() { diff --git a/packages/testing/src/server/notifications/notification.delivery.ts b/packages/testing/src/server/notifications/notification.delivery.ts new file mode 100644 index 0000000..d950973 --- /dev/null +++ b/packages/testing/src/server/notifications/notification.delivery.ts @@ -0,0 +1,37 @@ +import { MockEventTransport } from '../../events/mock-event.transport.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { NotificationPresenter } from './notifications.presenter.js'; +import type { NotificationService } from './notifications.service.js'; +import type { NotificationRecord } from './notifications.types.js'; + +/** Доставляет новые уведомления в подключённые транспорты событий получателя. @internal */ +export class NotificationDelivery { + readonly #transports = new Map>(); + readonly #notifications: NotificationService; + readonly #presenter: NotificationPresenter; + + constructor(notifications: NotificationService, presenter: NotificationPresenter) { + this.#notifications = notifications; + this.#presenter = presenter; + notifications.onCreated((record) => this.#deliver(record)); + } + + /** Создаёт транспорт, который получает уведомления пользователя. */ + connect(user: UserRecord): MockEventTransport { + const transport = new MockEventTransport(); + const transports = this.#transports.get(user.profile.id) ?? new Set(); + transports.add(transport); + this.#transports.set(user.profile.id, transports); + return transport; + } + + #deliver(record: NotificationRecord): void { + const transports = this.#transports.get(record.userId); + if (!transports) return; + const notification = this.#presenter.notification(record); + const unread = this.#notifications.unreadCount(record.userId); + for (const transport of transports) { + if (transport.connected) transport.notification(notification, unread); + } + } +} diff --git a/packages/testing/src/server/notifications/notifications.presenter.ts b/packages/testing/src/server/notifications/notifications.presenter.ts new file mode 100644 index 0000000..cf9239e --- /dev/null +++ b/packages/testing/src/server/notifications/notifications.presenter.ts @@ -0,0 +1,44 @@ +import type { Notification } from 'itd-api'; +import { notificationFixture } from '../../fixtures/models.fixtures.js'; +import type { OffsetSlice } from '../shared/pagination.types.js'; +import type { UserPresenter } from '../users/users.presenter.js'; +import type { UserService } from '../users/users.service.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { NotificationPage, NotificationRecord } from './notifications.types.js'; + +/** Уведомления в форме ответов API. Участники берутся из текущих профилей. @internal */ +export class NotificationPresenter { + readonly #users: UserService; + readonly #userPresenter: UserPresenter; + + constructor(users: UserService, userPresenter: UserPresenter) { + this.#users = users; + this.#userPresenter = userPresenter; + } + + page(slice: OffsetSlice): NotificationPage { + return { + notifications: slice.items.map((record) => this.notification(record)), + hasMore: slice.hasMore, + }; + } + + notification(record: NotificationRecord): Notification { + const actors = record.actorIds + .map((id) => this.#users.get(id)) + .filter((user): user is UserRecord => user !== undefined) + .map((user) => this.#userPresenter.reference(user)); + return notificationFixture({ + id: record.id, + type: record.type, + rawType: record.type, + actors, + entityId: record.entityId, + parentEntityId: record.parentEntityId, + preview: record.preview, + isRead: record.isRead, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + } +} diff --git a/packages/testing/src/server/notifications/notifications.routes.ts b/packages/testing/src/server/notifications/notifications.routes.ts new file mode 100644 index 0000000..0ff1517 --- /dev/null +++ b/packages/testing/src/server/notifications/notifications.routes.ts @@ -0,0 +1,61 @@ +import { HttpMethod } from '../../http/http.constants.js'; +import { apiResponse, jsonResponse } from '../../http/responses.utils.js'; +import { offsetPage } from '../shared/pagination.utils.js'; +import { objectBody, offsetQuery } from '../shared/request.utils.js'; +import type { MockRouteContext } from '../shared/route.types.js'; + +export function registerNotificationRoutes({ + services, + presenters, + route, + requireAuth, +}: MockRouteContext): void { + const { notifications } = services; + + route( + HttpMethod.Get, + '/api/notifications/', + requireAuth((request, user) => { + const page = offsetPage(notifications.forUser(user.profile.id), offsetQuery(request)); + return jsonResponse(presenters.notifications.page(page)); + }), + ); + + route( + HttpMethod.Get, + '/api/notifications/count', + requireAuth((_request, user) => + apiResponse({ count: notifications.unreadCount(user.profile.id) }), + ), + ); + + route( + HttpMethod.Post, + '/api/notifications/:notificationId/read', + requireAuth((request, user) => + apiResponse({ + markedCount: notifications.markRead(user.profile.id, request.params.notificationId ?? ''), + }), + ), + ); + + route( + HttpMethod.Post, + '/api/notifications/read-batch', + requireAuth((request, user) => { + const ids = objectBody(request).ids; + const selected = new Set( + Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : [], + ); + return apiResponse({ markedCount: notifications.markMany(user.profile.id, selected) }); + }), + ); + + route( + HttpMethod.Post, + '/api/notifications/read-all', + requireAuth((_request, user) => + apiResponse({ markedCount: notifications.markAll(user.profile.id) }), + ), + ); +} diff --git a/packages/testing/src/server/notifications/notifications.service.ts b/packages/testing/src/server/notifications/notifications.service.ts new file mode 100644 index 0000000..f0b820e --- /dev/null +++ b/packages/testing/src/server/notifications/notifications.service.ts @@ -0,0 +1,81 @@ +import { MockDomainError } from '../shared/domain.errors.js'; +import type { MockStore } from '../store/mock.store.js'; +import type { NotificationInput, NotificationRecord } from './notifications.types.js'; + +type NotificationListener = (record: NotificationRecord) => void; + +/** Хранит уведомления и оповещает подписчиков о новых. Когда уведомлять, решают другие сервисы. @internal */ +export class NotificationService { + readonly #store: MockStore; + readonly #listeners = new Set(); + + constructor(store: MockStore) { + this.#store = store; + } + + /** Создаёт уведомление. Действие над собой и неизвестный получатель уведомления не создают. */ + notify(input: NotificationInput): NotificationRecord | undefined { + if (input.recipientId === input.actorId || !this.#store.users.has(input.recipientId)) { + return undefined; + } + const createdAt = this.#store.now(); + const record: NotificationRecord = { + id: this.#store.nextNotificationId(), + userId: input.recipientId, + type: input.type, + actorIds: [input.actorId], + entityId: input.entityId, + parentEntityId: input.parentEntityId ?? null, + preview: input.preview ?? null, + isRead: false, + createdAt, + updatedAt: createdAt, + }; + this.#store.notifications.unshift(record); + for (const listener of this.#listeners) listener(record); + return record; + } + + /** Подписывает на новые уведомления. */ + onCreated(listener: NotificationListener): void { + this.#listeners.add(listener); + } + + /** Уведомления пользователя, сначала новые. */ + forUser(userId: string): NotificationRecord[] { + return this.#store.notifications.filter((record) => record.userId === userId); + } + + unreadCount(userId: string): number { + return this.#store.notifications.filter((record) => record.userId === userId && !record.isRead) + .length; + } + + /** Возвращает, сколько уведомлений стало прочитанными. */ + markRead(userId: string, notificationId: string): number { + const record = this.#store.notifications.find( + (candidate) => candidate.userId === userId && candidate.id === notificationId, + ); + if (!record) throw MockDomainError.notFound('Notification not found'); + return this.#setRead([record]); + } + + markMany(userId: string, notificationIds: ReadonlySet): number { + return this.#setRead(this.forUser(userId).filter((record) => notificationIds.has(record.id))); + } + + markAll(userId: string): number { + return this.#setRead(this.forUser(userId)); + } + + #setRead(records: readonly NotificationRecord[]): number { + let marked = 0; + for (const record of records) { + if (record.isRead) continue; + record.isRead = true; + record.updatedAt = this.#store.now(); + marked += 1; + } + return marked; + } +} diff --git a/packages/testing/src/server/notifications/notifications.types.ts b/packages/testing/src/server/notifications/notifications.types.ts new file mode 100644 index 0000000..07bb080 --- /dev/null +++ b/packages/testing/src/server/notifications/notifications.types.ts @@ -0,0 +1,31 @@ +import type { Notification } from 'itd-api'; + +/** Уведомление в хранилище mock-server. Участники хранятся ссылками на пользователей. @internal */ +export interface NotificationRecord { + id: string; + userId: string; + type: Notification['type']; + actorIds: string[]; + entityId: string | null; + parentEntityId: string | null; + preview: string | null; + isRead: boolean; + createdAt: string; + updatedAt: string; +} + +/** Что произошло и с кем: из этого сервис собирает запись уведомления. @internal */ +export interface NotificationInput { + recipientId: string; + type: Notification['type']; + actorId: string; + entityId: string | null; + parentEntityId?: string | null; + preview?: string | null; +} + +/** Страница уведомлений в форме ответа API: без обёртки `data` и с признаком продолжения. @internal */ +export interface NotificationPage { + notifications: Notification[]; + hasMore: boolean; +} diff --git a/packages/testing/src/server/posts/posts.presenter.ts b/packages/testing/src/server/posts/posts.presenter.ts new file mode 100644 index 0000000..149a4b3 --- /dev/null +++ b/packages/testing/src/server/posts/posts.presenter.ts @@ -0,0 +1,99 @@ +import type { LikeResult, OriginalPost, Post, PostUpdateResult } from 'itd-api'; +import { postFixture } from '../../fixtures/models.fixtures.js'; +import type { CommentService } from '../comments/comments.service.js'; +import type { CursorSlice } from '../shared/pagination.types.js'; +import { cursorPagination } from '../shared/pagination.utils.js'; +import type { UserPresenter } from '../users/users.presenter.js'; +import type { UserService } from '../users/users.service.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { PostService } from './posts.service.js'; +import type { PostPage, PostRecord } from './posts.types.js'; +import { postgresStamp } from './posts.utils.js'; + +/** Посты в форме ответов API относительно текущего пользователя. @internal */ +export class PostPresenter { + readonly #users: UserService; + readonly #posts: PostService; + readonly #comments: CommentService; + readonly #userPresenter: UserPresenter; + + constructor( + users: UserService, + posts: PostService, + comments: CommentService, + userPresenter: UserPresenter, + ) { + this.#users = users; + this.#posts = posts; + this.#comments = comments; + this.#userPresenter = userPresenter; + } + + post(post: PostRecord, viewer: UserRecord): Post { + const parent = this.#posts.activeParent(post); + return postFixture({ + id: post.id, + content: post.content, + spans: structuredClone(post.spans), + author: this.#author(post), + wallRecipientId: post.wallRecipientId, + likesCount: post.likedBy.size, + commentsCount: this.#comments.activeCountFor(post), + repostsCount: this.#posts.repostsCount(post), + isLiked: post.likedBy.has(viewer.profile.id), + isReposted: this.#posts.hasReposted(viewer, post.id), + isOwner: post.authorId === viewer.profile.id, + originalPost: parent ? this.originalPost(parent) : null, + editedAt: post.editedAt, + createdAt: post.createdAt, + }); + } + + /** Курсорная страница постов в форме ответа API: `{ posts, pagination }`. */ + page(slice: CursorSlice, viewer: UserRecord): PostPage { + return { + posts: slice.items.map((post) => this.post(post, viewer)), + pagination: cursorPagination(slice), + }; + } + + updateResult(post: PostRecord): PostUpdateResult { + return { + id: post.id, + content: post.content, + spans: structuredClone(post.spans), + updatedAt: post.editedAt ?? post.createdAt, + }; + } + + likeResult(post: PostRecord, liked: boolean): LikeResult { + return { liked, likesCount: post.likedBy.size }; + } + + /** + * Пост, который репостнули: один уровень, без признаков относительно текущего пользователя. + * Дата — в формате PostgreSQL, как на проде. Удалённый родитель вместо объекта отдаётся `null`, + * поэтому `isDeleted` здесь всегда `false`. + */ + originalPost(post: PostRecord): OriginalPost { + return { + id: post.id, + content: post.content, + spans: structuredClone(post.spans), + author: this.#author(post), + attachments: [], + likesCount: post.likedBy.size, + commentsCount: this.#comments.activeCountFor(post), + repostsCount: this.#posts.repostsCount(post), + viewsCount: 0, + isDeleted: false, + createdAt: postgresStamp(post.createdAt), + }; + } + + #author(post: PostRecord): Post['author'] { + const author = this.#users.get(post.authorId); + if (!author) throw new Error(`У поста ${post.id} нет автора ${post.authorId}`); + return this.#userPresenter.author(author); + } +} diff --git a/packages/testing/src/server/posts/posts.routes.ts b/packages/testing/src/server/posts/posts.routes.ts new file mode 100644 index 0000000..ccde9ce --- /dev/null +++ b/packages/testing/src/server/posts/posts.routes.ts @@ -0,0 +1,150 @@ +import { FeedTab } from 'itd-api'; +import { HttpMethod } from '../../http/http.constants.js'; +import type { MockRequest } from '../../http/request.types.js'; +import { apiResponse, emptyResponse } from '../../http/responses.utils.js'; +import type { MockHandler } from '../../http/router.types.js'; +import { MockDomainError } from '../shared/domain.errors.js'; +import { cursorPage } from '../shared/pagination.utils.js'; +import { cursorQuery, objectBody, spansField, stringField } from '../shared/request.utils.js'; +import type { MockRouteContext } from '../shared/route.types.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { PostRecord } from './posts.types.js'; + +/** Отклоняет вложения и опросы: mock-server их не моделирует. */ +function rejectUnsupportedPostFields(body: Record): void { + for (const field of ['attachmentIds', 'poll']) { + if (field in body) { + throw MockDomainError.validation(`mock-server не поддерживает ${field} при создании поста`); + } + } +} + +export function registerPostRoutes({ + services, + presenters, + route, + requireAuth, +}: MockRouteContext): void { + const { posts, users } = services; + + const postPage = (request: MockRequest, viewer: UserRecord, items: readonly PostRecord[]) => + apiResponse(presenters.posts.page(cursorPage(items, cursorQuery(request)), viewer)); + + const feedOf = (request: MockRequest, viewer: UserRecord): PostRecord[] => { + const tab = request.query.get('tab'); + switch (tab) { + case null: + case FeedTab.Popular: + return posts.feed(); + case FeedTab.Following: + return posts.followingFeed(viewer); + case FeedTab.Clan: + return posts.clanFeed(viewer); + default: + throw MockDomainError.validation(`Неизвестная вкладка ленты ${tab}`); + } + }; + + route( + HttpMethod.Get, + '/api/posts', + requireAuth((request, viewer) => postPage(request, viewer, feedOf(request, viewer))), + ); + + route( + HttpMethod.Get, + '/api/posts/user/:user', + requireAuth((request, viewer) => + postPage(request, viewer, posts.wall(users.require(request.params.user ?? ''))), + ), + ); + + route( + HttpMethod.Get, + '/api/posts/user/:user/liked', + requireAuth((request, viewer) => + postPage(request, viewer, posts.likedBy(users.require(request.params.user ?? ''), viewer)), + ), + ); + + route( + HttpMethod.Post, + '/api/posts', + requireAuth((request, user) => { + const body = objectBody(request); + rejectUnsupportedPostFields(body); + const post = posts.create(user, { + content: stringField(body, 'content') ?? '', + spans: spansField(body) ?? [], + wallRecipientId: stringField(body, 'wallRecipientId') ?? null, + }); + return apiResponse(presenters.posts.post(post, user), { status: 201 }); + }), + ); + + route( + HttpMethod.Get, + '/api/posts/:postId', + requireAuth((request, viewer) => + apiResponse(presenters.posts.post(posts.requireActive(request.params.postId ?? ''), viewer)), + ), + ); + + route( + HttpMethod.Put, + '/api/posts/:postId', + requireAuth((request, user) => { + const post = posts.requireOwn(request.params.postId ?? '', user); + const body = objectBody(request); + posts.edit(post, { content: stringField(body, 'content'), spans: spansField(body) }); + return apiResponse(presenters.posts.updateResult(post)); + }), + ); + + route( + HttpMethod.Delete, + '/api/posts/:postId', + requireAuth((request, user) => { + posts.remove(posts.requireOwn(request.params.postId ?? '', user)); + return emptyResponse(); + }), + ); + + route( + HttpMethod.Post, + '/api/posts/:postId/restore', + requireAuth((request, user) => { + posts.restore(posts.requireOwn(request.params.postId ?? '', user)); + return emptyResponse(); + }), + ); + + route( + HttpMethod.Post, + '/api/posts/:postId/repost', + requireAuth((request, user) => { + const content = stringField(objectBody(request), 'content') ?? ''; + const repost = posts.repost(user, request.params.postId ?? '', content); + return apiResponse(presenters.posts.post(repost, user), { status: 201 }); + }), + ); + + route( + HttpMethod.Delete, + '/api/posts/:postId/repost', + requireAuth((request, user) => { + const repostsCount = posts.unrepost(user, request.params.postId ?? ''); + return apiResponse({ success: true, repostsCount }); + }), + ); + + const reaction = (liked: boolean): MockHandler => + requireAuth((request, user) => { + const post = posts.requireActive(request.params.postId ?? ''); + if (liked) posts.like(user, post); + else posts.unlike(user, post); + return apiResponse(presenters.posts.likeResult(post, liked)); + }); + route(HttpMethod.Post, '/api/posts/:postId/like', reaction(true)); + route(HttpMethod.Delete, '/api/posts/:postId/like', reaction(false)); +} diff --git a/packages/testing/src/server/posts/posts.service.ts b/packages/testing/src/server/posts/posts.service.ts new file mode 100644 index 0000000..15b4b05 --- /dev/null +++ b/packages/testing/src/server/posts/posts.service.ts @@ -0,0 +1,254 @@ +import { ItdErrorCode, NotificationType } from 'itd-api'; +import type { NotificationService } from '../notifications/notifications.service.js'; +import { canAccess } from '../shared/access.utils.js'; +import { MockDomainError } from '../shared/domain.errors.js'; +import type { MockStore } from '../store/mock.store.js'; +import type { UserService } from '../users/users.service.js'; +import type { UserRecord } from '../users/users.types.js'; +import { clanOf } from '../users/users.utils.js'; +import type { NewPost, PostEdit, PostRecord } from './posts.types.js'; + +function newestFirst(a: PostRecord, b: PostRecord): number { + return b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id); +} + +/** Посты, их публикация, удаление и реакции. @internal */ +export class PostService { + readonly #store: MockStore; + readonly #users: UserService; + readonly #notifications: NotificationService; + + constructor(store: MockStore, users: UserService, notifications: NotificationService) { + this.#store = store; + this.#users = users; + this.#notifications = notifications; + } + + /** Все активные посты, сначала новые. */ + feed(): PostRecord[] { + return this.#active(() => true); + } + + /** Активные посты авторов, на которых подписан пользователь. */ + followingFeed(viewer: UserRecord): PostRecord[] { + return this.#active((post) => viewer.following.has(post.authorId)); + } + + /** Активные посты авторов из клана пользователя (`clanAvatar`), включая его собственные. */ + clanFeed(viewer: UserRecord): PostRecord[] { + const clan = clanOf(viewer); + return this.#active((post) => { + const author = this.#users.get(post.authorId); + return author !== undefined && clanOf(author) === clan; + }); + } + + /** + * Активные посты, которые лайкнул владелец списка. Если `likesVisibility` закрывает список + * от обращающегося, список пуст: прод в этом случае не отвечает ошибкой. + */ + likedBy(owner: UserRecord, viewer: UserRecord): PostRecord[] { + const visible = canAccess(owner.profile.likesVisibility, this.#users.relation(owner, viewer)); + return visible ? this.#active((post) => post.likedBy.has(owner.profile.id)) : []; + } + + /** Активные посты на стене пользователя: свои без адресата и адресованные ему. */ + wall(owner: UserRecord): PostRecord[] { + return this.#active((post) => (post.wallRecipientId ?? post.authorId) === owner.profile.id); + } + + activeCountBy(author: UserRecord): number { + let count = 0; + for (const post of this.#store.posts.values()) { + if (!post.deleted && post.authorId === author.profile.id) count += 1; + } + return count; + } + + /** Видимый пост: не удалён, автор не деактивирован. */ + requireActive(postId: string): PostRecord { + const post = this.#store.posts.get(postId); + if (!post || !this.#visible(post)) throw MockDomainError.notFound('Post not found'); + return post; + } + + /** Пост автора, в том числе удалённый: автор может его восстановить. */ + requireOwn(postId: string, author: UserRecord): PostRecord { + const post = this.#store.posts.get(postId); + if (!post) throw MockDomainError.notFound('Post not found'); + if (post.authorId !== author.profile.id) { + throw MockDomainError.forbidden('Пост принадлежит другому пользователю'); + } + return post; + } + + /** + * Публикует пост. Пост на своей стене хранится без адресата; на чужую стену можно писать, + * только если её `wallAccess` разрешает это автору. + */ + create(author: UserRecord, input: NewPost): PostRecord { + const post = this.#insert({ + authorId: author.profile.id, + content: input.content, + spans: input.spans, + originalPostId: null, + wallRecipientId: this.#wallRecipient(author, input.wallRecipientId), + }); + if (post.wallRecipientId) { + this.#notifications.notify({ + recipientId: post.wallRecipientId, + type: NotificationType.WallPost, + actorId: author.profile.id, + entityId: post.id, + preview: post.content, + }); + } + return post; + } + + edit(post: PostRecord, changes: PostEdit): void { + if (changes.content !== undefined) { + post.content = changes.content; + post.spans = changes.spans ?? []; + } else if (changes.spans !== undefined) { + post.spans = changes.spans; + } + post.editedAt = this.#store.now(); + } + + remove(post: PostRecord): void { + post.deleted = true; + } + + restore(post: PostRecord): void { + post.deleted = false; + } + + /** + * Репостит пост с необязательным текстом. Каждый вызов создаёт новый репост, но автор поста + * получает уведомление только о первом активном репосте пользователя. Репост репоста + * ссылается на сам репост, поэтому цепочка может быть любой длины. + */ + repost(user: UserRecord, postId: string, content: string): PostRecord { + const parent = this.requireActive(postId); + const firstRepost = !this.hasReposted(user, parent.id); + const repost = this.#insert({ + authorId: user.profile.id, + content, + spans: [], + originalPostId: parent.id, + wallRecipientId: null, + }); + if (firstRepost) { + this.#notifications.notify({ + recipientId: parent.authorId, + type: NotificationType.PostRepost, + actorId: user.profile.id, + entityId: repost.id, + preview: parent.content, + }); + } + return repost; + } + + /** + * Удаляет все репосты поста от пользователя и возвращает, сколько пользователей его ещё + * репостят. Сам пост и чужие репосты не меняются; удалённый исходный пост отмене не мешает. + */ + unrepost(user: UserRecord, postId: string): number { + const reposts = this.#repostsBy(user, postId); + if (reposts.length === 0) throw MockDomainError.notFound('Repost not found'); + for (const repost of reposts) repost.deleted = true; + return this.#countReposters(postId); + } + + /** Есть ли у пользователя активный репост поста. */ + hasReposted(user: UserRecord, postId: string): boolean { + return this.#repostsBy(user, postId).length > 0; + } + + /** Сколько пользователей репостят пост: несколько репостов одного пользователя считаются одним. */ + repostsCount(post: PostRecord): number { + return this.#countReposters(post.id); + } + + /** Пост, который репостнули, если он виден. */ + activeParent(post: PostRecord): PostRecord | undefined { + if (!post.originalPostId) return undefined; + const parent = this.#store.posts.get(post.originalPostId); + return parent && this.#visible(parent) ? parent : undefined; + } + + /** Ставит реакцию. Уведомление автор получает только о новой реакции. */ + like(user: UserRecord, post: PostRecord): void { + if (post.likedBy.has(user.profile.id)) return; + post.likedBy.add(user.profile.id); + this.#notifications.notify({ + recipientId: post.authorId, + type: NotificationType.PostReaction, + actorId: user.profile.id, + entityId: post.id, + preview: post.content, + }); + } + + unlike(user: UserRecord, post: PostRecord): void { + post.likedBy.delete(user.profile.id); + } + + #insert( + fields: Omit, + ): PostRecord { + const post: PostRecord = { + id: this.#store.nextPostId(), + ...fields, + createdAt: this.#store.now(), + editedAt: null, + likedBy: new Set(), + deleted: false, + }; + this.#store.posts.set(post.id, post); + return post; + } + + #active(predicate: (post: PostRecord) => boolean): PostRecord[] { + return [...this.#store.posts.values()] + .filter((post) => this.#visible(post) && predicate(post)) + .sort(newestFirst); + } + + #repostsBy(user: UserRecord, postId: string): PostRecord[] { + return [...this.#store.posts.values()].filter( + (post) => + !post.deleted && post.originalPostId === postId && post.authorId === user.profile.id, + ); + } + + #countReposters(postId: string): number { + const reposters = new Set(); + for (const candidate of this.#store.posts.values()) { + if (candidate.originalPostId === postId && this.#visible(candidate)) { + reposters.add(candidate.authorId); + } + } + return reposters.size; + } + + #visible(post: PostRecord): boolean { + return !post.deleted && this.#users.isActive(post.authorId); + } + + #wallRecipient(author: UserRecord, recipientId: string | null): string | null { + if (!recipientId || recipientId === author.profile.id) return null; + const recipient = this.#users.get(recipientId); + if (!recipient || recipient.deactivated) throw MockDomainError.notFound('User not found'); + if (!canAccess(recipient.profile.wallAccess, this.#users.relation(recipient, author))) { + throw new MockDomainError( + 403, + ItdErrorCode.WRITE_ACCESS_RESTRICTED, + 'Стена закрыта для записей', + ); + } + return recipient.profile.id; + } +} diff --git a/packages/testing/src/server/posts/posts.types.ts b/packages/testing/src/server/posts/posts.types.ts new file mode 100644 index 0000000..93c355d --- /dev/null +++ b/packages/testing/src/server/posts/posts.types.ts @@ -0,0 +1,36 @@ +import type { Post, Span } from 'itd-api'; +import type { CursorPagination } from '../shared/pagination.types.js'; + +/** Пост в хранилище mock-server. @internal */ +export interface PostRecord { + id: string; + authorId: string; + content: string; + spans: Span[]; + /** Пост, который репостнули: непосредственный родитель, а не начало цепочки. */ + originalPostId: string | null; + wallRecipientId: string | null; + createdAt: string; + editedAt: string | null; + likedBy: Set; + deleted: boolean; +} + +/** Данные нового поста. @internal */ +export interface NewPost { + content: string; + spans: Span[]; + wallRecipientId: string | null; +} + +/** Изменение поста. Новый текст без разметки сбрасывает прежнюю разметку. @internal */ +export interface PostEdit { + content?: string | undefined; + spans?: Span[] | undefined; +} + +/** Курсорная страница постов в форме ответа API. @internal */ +export interface PostPage { + posts: Post[]; + pagination: CursorPagination; +} diff --git a/packages/testing/src/server/posts/posts.utils.ts b/packages/testing/src/server/posts/posts.utils.ts new file mode 100644 index 0000000..2e520fe --- /dev/null +++ b/packages/testing/src/server/posts/posts.utils.ts @@ -0,0 +1,9 @@ +/** + * Дата в формате PostgreSQL, в котором прод отдаёт `originalPost.createdAt`: + * `2026-10-04 19:32:04.381000+00`. @internal + */ +export function postgresStamp(iso: string): string { + const date = new Date(iso); + if (!Number.isFinite(date.getTime())) return iso; + return `${date.toISOString().replace('T', ' ').replace('Z', '')}000+00`; +} diff --git a/packages/testing/src/server/routes/comments.ts b/packages/testing/src/server/routes/comments.ts deleted file mode 100644 index 68ac003..0000000 --- a/packages/testing/src/server/routes/comments.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { NotificationType } from 'itd-api'; -import { HttpMethod } from '../../constants.js'; -import { apiErrorResponse, apiResponse, emptyResponse } from '../../responses.js'; -import type { MockHandler } from '../../router.js'; -import type { CommentState, UserState } from '../entities.js'; -import { cursorPage, type MockRouteContext, objectBody, positiveInt } from './context.js'; - -export function registerCommentRoutes(context: MockRouteContext): void { - const { state, route, requireAuth } = context; - - const ownComment = (commentId: string, user: UserState): CommentState | Response => { - const value = state.comments.get(commentId); - if (!value) { - return apiErrorResponse(404, 'COMMENT_NOT_FOUND', 'Комментарий не найден'); - } - if (value.authorId !== user.profile.id) { - return apiErrorResponse(403, 'FORBIDDEN', 'Комментарий принадлежит другому пользователю'); - } - return value; - }; - - route( - HttpMethod.Get, - '/api/posts/:postId/comments', - requireAuth((request, viewer) => { - const post = state.posts.get(request.params.postId ?? ''); - if (!post || post.deleted) { - return apiErrorResponse(404, 'POST_NOT_FOUND', 'Пост не найден'); - } - const sorted = [...state.comments.values()] - .filter( - (comment) => - comment.postId === post.id && comment.parentCommentId === null && !comment.deleted, - ) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); - const page = cursorPage(sorted, request); - return apiResponse({ - comments: page.items.map((comment) => state.commentModel(comment, viewer)), - hasMore: page.hasMore, - nextCursor: page.next, - total: sorted.length, - }); - }), - ); - - route( - HttpMethod.Post, - '/api/posts/:postId/comments', - requireAuth((request, user) => { - const post = state.posts.get(request.params.postId ?? ''); - if (!post || post.deleted) { - return apiErrorResponse(404, 'POST_NOT_FOUND', 'Пост не найден'); - } - const body = objectBody(request); - const comment: CommentState = { - id: state.nextCommentId(), - postId: post.id, - authorId: user.profile.id, - parentCommentId: null, - replyToUserId: undefined, - content: typeof body.content === 'string' ? body.content : '', - createdAt: state.now(), - likedBy: new Set(), - deleted: false, - }; - state.comments.set(comment.id, comment); - state.pushNotification( - post.authorId, - NotificationType.PostComment, - user, - comment.id, - post.id, - comment.content, - ); - return apiResponse(state.commentModel(comment, user), { status: 201 }); - }), - ); - - route( - HttpMethod.Get, - '/api/comments/:commentId/replies', - requireAuth((request, viewer) => { - const parent = state.comments.get(request.params.commentId ?? ''); - if (!parent || parent.deleted) { - return apiErrorResponse(404, 'COMMENT_NOT_FOUND', 'Комментарий не найден'); - } - const all = [...state.comments.values()] - .filter((comment) => comment.parentCommentId === parent.id && !comment.deleted) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); - const page = positiveInt(request.query.get('page'), 1); - const limit = positiveInt(request.query.get('limit'), 20); - const items = all.slice((page - 1) * limit, page * limit); - return apiResponse({ - replies: items.map((comment) => state.commentModel(comment, viewer)), - pagination: { - page, - limit, - total: all.length, - hasMore: page * limit < all.length, - }, - }); - }), - ); - - route( - HttpMethod.Post, - '/api/comments/:commentId/replies', - requireAuth((request, user) => { - const parent = state.comments.get(request.params.commentId ?? ''); - if (!parent || parent.deleted) { - return apiErrorResponse(404, 'COMMENT_NOT_FOUND', 'Комментарий не найден'); - } - const body = objectBody(request); - const replyToUserId = - typeof body.replyToUserId === 'string' ? body.replyToUserId : parent.authorId; - if (!state.users.has(replyToUserId)) { - return apiErrorResponse(404, 'USER_NOT_FOUND', 'Адресат ответа не найден'); - } - const reply: CommentState = { - id: state.nextCommentId(), - postId: parent.postId, - authorId: user.profile.id, - parentCommentId: parent.id, - replyToUserId, - content: typeof body.content === 'string' ? body.content : '', - createdAt: state.now(), - likedBy: new Set(), - deleted: false, - }; - state.comments.set(reply.id, reply); - state.pushNotification( - replyToUserId, - NotificationType.CommentReply, - user, - reply.id, - parent.postId, - reply.content, - ); - return apiResponse(state.commentModel(reply, user), { status: 201 }); - }), - ); - - route( - HttpMethod.Patch, - '/api/comments/:commentId', - requireAuth((request, user) => { - const comment = ownComment(request.params.commentId ?? '', user); - if (comment instanceof Response) return comment; - const body = objectBody(request); - if (typeof body.content === 'string') comment.content = body.content; - return apiResponse({ id: comment.id, content: comment.content, editedAt: state.now() }); - }), - ); - - route( - HttpMethod.Delete, - '/api/comments/:commentId', - requireAuth((request, user) => { - const comment = ownComment(request.params.commentId ?? '', user); - if (comment instanceof Response) return comment; - comment.deleted = true; - return emptyResponse(); - }), - ); - - route( - HttpMethod.Post, - '/api/comments/:commentId/restore', - requireAuth((request, user) => { - const comment = ownComment(request.params.commentId ?? '', user); - if (comment instanceof Response) return comment; - comment.deleted = false; - return emptyResponse(); - }), - ); - - const commentLike = (liked: boolean): MockHandler => - requireAuth((request, user) => { - const comment = state.comments.get(request.params.commentId ?? ''); - if (!comment || comment.deleted) { - return apiErrorResponse(404, 'COMMENT_NOT_FOUND', 'Комментарий не найден'); - } - if (liked) { - comment.likedBy.add(user.profile.id); - state.pushNotification( - comment.authorId, - NotificationType.CommentReaction, - user, - comment.id, - comment.postId, - comment.content, - ); - } else { - comment.likedBy.delete(user.profile.id); - } - return apiResponse({ liked, likesCount: comment.likedBy.size }); - }); - - route(HttpMethod.Post, '/api/comments/:commentId/like', commentLike(true)); - route(HttpMethod.Delete, '/api/comments/:commentId/like', commentLike(false)); -} diff --git a/packages/testing/src/server/routes/context.ts b/packages/testing/src/server/routes/context.ts deleted file mode 100644 index 50d1d58..0000000 --- a/packages/testing/src/server/routes/context.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { MockRequest } from '../../request.js'; -import { apiErrorResponse } from '../../responses.js'; -import { compileRoute, defineRoute, type MockHandler, type MockRoute } from '../../router.js'; -import type { UserState } from '../entities.js'; -import type { MockServerState } from '../state.js'; - -export interface RegisteredHandler { - route: MockRoute; - compiled: ReturnType; - handler: MockHandler; -} - -export interface MockRouteContext { - state: MockServerState; - route(method: string, path: string, handler: MockHandler): void; - requireAuth( - handler: (request: MockRequest, user: UserState) => Response | Promise, - ): MockHandler; -} - -export function createRouteContext( - state: MockServerState, - routes: RegisteredHandler[], -): MockRouteContext { - return { - state, - route(method, path, handler) { - const descriptor = defineRoute(method, path); - routes.push({ route: descriptor, compiled: compileRoute(descriptor), handler }); - }, - requireAuth(handler) { - return (request) => { - const user = state.authUser(request); - return user - ? handler(request, user) - : apiErrorResponse(401, 'UNAUTHORIZED', 'Нужна авторизация'); - }; - }, - }; -} - -export function objectBody(request: MockRequest): Record { - return typeof request.json === 'object' && request.json !== null && !Array.isArray(request.json) - ? (request.json as Record) - : {}; -} - -export function positiveInt(value: string | null, fallback: number, maximum = 100): number { - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; -} - -export function cursorPage( - items: readonly T[], - request: MockRequest, -): { items: T[]; next: string | null; hasMore: boolean; limit: number } { - const limit = positiveInt(request.query.get('limit'), 20); - const offset = Math.max(0, Number.parseInt(request.query.get('cursor') ?? '0', 10) || 0); - const page = items.slice(offset, offset + limit); - const nextOffset = offset + page.length; - return { - items: page, - next: nextOffset < items.length ? String(nextOffset) : null, - hasMore: nextOffset < items.length, - limit, - }; -} diff --git a/packages/testing/src/server/routes/notifications.ts b/packages/testing/src/server/routes/notifications.ts deleted file mode 100644 index 825f21c..0000000 --- a/packages/testing/src/server/routes/notifications.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { HttpMethod } from '../../constants.js'; -import { apiErrorResponse, apiResponse, jsonResponse } from '../../responses.js'; -import { type MockRouteContext, objectBody, positiveInt } from './context.js'; - -export function registerNotificationRoutes({ state, route, requireAuth }: MockRouteContext): void { - route( - HttpMethod.Get, - '/api/notifications/', - requireAuth((request, user) => { - const all = state.notifications - .filter((notification) => notification.userId === user.profile.id) - .map((notification) => notification.value); - const offset = Math.max(0, Number.parseInt(request.query.get('offset') ?? '0', 10) || 0); - const limit = positiveInt(request.query.get('limit'), 20); - return jsonResponse({ - notifications: all.slice(offset, offset + limit), - hasMore: offset + limit < all.length, - }); - }), - ); - - route( - HttpMethod.Get, - '/api/notifications/count', - requireAuth((_request, user) => apiResponse({ count: state.unreadCount(user.profile.id) })), - ); - - route( - HttpMethod.Post, - '/api/notifications/:notificationId/read', - requireAuth((request, user) => { - const notification = state.notifications.find( - (candidate) => - candidate.userId === user.profile.id && - candidate.value.id === request.params.notificationId, - ); - if (!notification) { - return apiErrorResponse(404, 'NOTIFICATION_NOT_FOUND', 'Уведомление не найдено'); - } - const markedCount = notification.value.isRead ? 0 : 1; - notification.value = { - ...notification.value, - isRead: true, - updatedAt: state.now(), - }; - return apiResponse({ markedCount }); - }), - ); - - route( - HttpMethod.Post, - '/api/notifications/read-batch', - requireAuth((request, user) => { - const ids = objectBody(request).ids; - const selected = new Set( - Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : [], - ); - let markedCount = 0; - for (const notification of state.notifications) { - if ( - notification.userId === user.profile.id && - selected.has(notification.value.id) && - !notification.value.isRead - ) { - notification.value = { - ...notification.value, - isRead: true, - updatedAt: state.now(), - }; - markedCount += 1; - } - } - return apiResponse({ markedCount }); - }), - ); - - route( - HttpMethod.Post, - '/api/notifications/read-all', - requireAuth((_request, user) => { - let markedCount = 0; - for (const notification of state.notifications) { - if (notification.userId === user.profile.id && !notification.value.isRead) { - notification.value = { - ...notification.value, - isRead: true, - updatedAt: state.now(), - }; - markedCount += 1; - } - } - return apiResponse({ markedCount }); - }), - ); -} diff --git a/packages/testing/src/server/routes/posts.ts b/packages/testing/src/server/routes/posts.ts deleted file mode 100644 index b81c15f..0000000 --- a/packages/testing/src/server/routes/posts.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { NotificationType } from 'itd-api'; -import { HttpMethod } from '../../constants.js'; -import { apiErrorResponse, apiResponse, emptyResponse } from '../../responses.js'; -import type { MockHandler } from '../../router.js'; -import type { PostState, UserState } from '../entities.js'; -import { cursorPage, type MockRouteContext, objectBody } from './context.js'; - -export function registerPostRoutes(context: MockRouteContext): void { - const { state, route, requireAuth } = context; - const ownPost = (postId: string, user: UserState): PostState | Response => { - const value = state.posts.get(postId); - if (!value) return apiErrorResponse(404, 'POST_NOT_FOUND', 'Пост не найден'); - if (value.authorId !== user.profile.id) { - return apiErrorResponse(403, 'FORBIDDEN', 'Пост принадлежит другому пользователю'); - } - return value; - }; - - route( - HttpMethod.Get, - '/api/posts', - requireAuth((request, viewer) => { - const sorted = [...state.posts.values()] - .filter((post) => !post.deleted) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)); - const page = cursorPage(sorted, request); - return apiResponse({ - posts: page.items.map((item) => state.postModel(item, viewer)), - pagination: { hasMore: page.hasMore, nextCursor: page.next, limit: page.limit }, - }); - }), - ); - - route( - HttpMethod.Get, - '/api/posts/user/:user', - requireAuth((request, viewer) => { - const target = state.findUser(request.params.user ?? ''); - if (!target) return apiErrorResponse(404, 'USER_NOT_FOUND', 'Пользователь не найден'); - const sorted = [...state.posts.values()] - .filter( - (post) => !post.deleted && (post.wallRecipientId ?? post.authorId) === target.profile.id, - ) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)); - const page = cursorPage(sorted, request); - return apiResponse({ - posts: page.items.map((item) => state.postModel(item, viewer)), - pagination: { hasMore: page.hasMore, nextCursor: page.next, limit: page.limit }, - }); - }), - ); - - route( - HttpMethod.Post, - '/api/posts', - requireAuth((request, user) => { - const body = objectBody(request); - const id = state.nextPostId(); - const value: PostState = { - id, - authorId: user.profile.id, - content: typeof body.content === 'string' ? body.content : '', - wallRecipientId: typeof body.wallRecipientId === 'string' ? body.wallRecipientId : null, - createdAt: state.now(), - editedAt: null, - likedBy: new Set(), - deleted: false, - }; - state.posts.set(id, value); - if (value.wallRecipientId) { - state.pushNotification( - value.wallRecipientId, - NotificationType.WallPost, - user, - id, - null, - value.content, - ); - } - return apiResponse(state.postModel(value, user), { status: 201 }); - }), - ); - - route( - HttpMethod.Get, - '/api/posts/:postId', - requireAuth((request, viewer) => { - const value = state.posts.get(request.params.postId ?? ''); - return value && !value.deleted - ? apiResponse(state.postModel(value, viewer)) - : apiErrorResponse(404, 'POST_NOT_FOUND', 'Пост не найден'); - }), - ); - - route( - HttpMethod.Put, - '/api/posts/:postId', - requireAuth((request, user) => { - const value = ownPost(request.params.postId ?? '', user); - if (value instanceof Response) return value; - const body = objectBody(request); - if (typeof body.content === 'string') value.content = body.content; - value.editedAt = state.now(); - return apiResponse({ - id: value.id, - content: value.content, - spans: [], - updatedAt: value.editedAt, - }); - }), - ); - - route( - HttpMethod.Delete, - '/api/posts/:postId', - requireAuth((request, user) => { - const value = ownPost(request.params.postId ?? '', user); - if (value instanceof Response) return value; - value.deleted = true; - return emptyResponse(); - }), - ); - - route( - HttpMethod.Post, - '/api/posts/:postId/restore', - requireAuth((request, user) => { - const value = ownPost(request.params.postId ?? '', user); - if (value instanceof Response) return value; - value.deleted = false; - return emptyResponse(); - }), - ); - - const postLike = (liked: boolean): MockHandler => - requireAuth((request, user) => { - const value = state.posts.get(request.params.postId ?? ''); - if (!value || value.deleted) { - return apiErrorResponse(404, 'POST_NOT_FOUND', 'Пост не найден'); - } - if (liked) { - value.likedBy.add(user.profile.id); - state.pushNotification( - value.authorId, - NotificationType.PostReaction, - user, - value.id, - null, - value.content, - ); - } else value.likedBy.delete(user.profile.id); - return apiResponse({ liked, likesCount: value.likedBy.size }); - }); - route(HttpMethod.Post, '/api/posts/:postId/like', postLike(true)); - route(HttpMethod.Delete, '/api/posts/:postId/like', postLike(false)); -} diff --git a/packages/testing/src/server/routes/shop.ts b/packages/testing/src/server/routes/shop.ts deleted file mode 100644 index bff423a..0000000 --- a/packages/testing/src/server/routes/shop.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { CreateShopOrderInput, ShopOrder, ShopOrderItem, ShopOrderSummary } from 'itd-api'; -import { HttpMethod } from '../../constants.js'; -import type { MockRequest } from '../../request.js'; -import { apiErrorResponse, emptyResponse, jsonResponse } from '../../responses.js'; -import type { ShopOrderState } from '../entities.js'; -import { type MockRouteContext, objectBody } from './context.js'; - -function orderSummary(order: ShopOrder): ShopOrderSummary { - return { - number: order.number, - titles: order.items.map((item) => item.title), - status: order.status, - total: order.total, - createdAt: order.createdAt, - }; -} - -function emailAccess(request: MockRequest, state: MockRouteContext['state']): string | undefined { - const token = request.headers.get('x-order-token'); - if (!token) return undefined; - const access = state.shopAccess.get(token); - if (!access) return undefined; - if (access.expiresAt > state.clock.now()) return access.email; - state.shopAccess.delete(token); - return undefined; -} - -function canOpen( - request: MockRequest, - order: ShopOrderState, - state: MockRouteContext['state'], -): boolean { - const user = state.authUser(request); - if (user && order.userId === user.profile.id) return true; - const token = request.headers.get('x-order-token'); - return token === order.accessToken || emailAccess(request, state) === order.email; -} - -function orderItems( - input: CreateShopOrderInput, - state: MockRouteContext['state'], -): ShopOrderItem[] { - return input.items.map((item) => { - const product = state.shopProducts.get(item.productId); - return { - slug: item.productId, - title: product?.title ?? item.productId, - color: item.color, - size: item.size, - qty: item.qty, - sum: (product?.price ?? 0) * item.qty, - }; - }); -} - -export function registerShopRoutes(context: MockRouteContext): void { - const { state, route } = context; - - route(HttpMethod.Get, '/api/v1/shop/products', () => - jsonResponse([...state.shopProducts.values()]), - ); - route(HttpMethod.Get, '/api/v1/shop/products/:productId', (request) => - state.shopProducts.has(request.params.productId ?? '') - ? jsonResponse(state.shopProducts.get(request.params.productId ?? '')) - : apiErrorResponse(404, 'NOT_FOUND', 'Товар не найден'), - ); - route(HttpMethod.Get, '/api/v1/shop/delivery/countries', () => - jsonResponse([{ code: 'RU', name: 'Россия' }]), - ); - route(HttpMethod.Get, '/api/v1/shop/delivery/cities', (request) => { - const query = request.query.get('q')?.trim() ?? ''; - return jsonResponse( - query ? [{ code: 44, name: query, countryCode: request.query.get('country') ?? 'RU' }] : [], - ); - }); - route(HttpMethod.Get, '/api/v1/shop/delivery/points', (request) => - jsonResponse([ - { - code: 'PVZ-1', - name: 'Пункт выдачи', - city: 'Москва', - cityCode: Number(request.query.get('cityCode')), - countryCode: 'RU', - postalCode: '101000', - address: 'Тестовая улица, 1', - latitude: 55.75, - longitude: 37.62, - dressingRoom: true, - card: true, - cash: false, - }, - ]), - ); - route(HttpMethod.Post, '/api/v1/shop/delivery/calculate', () => - jsonResponse({ - costKopecks: 50_000, - cost: 500, - periodMin: 2, - periodMax: 5, - tariffCode: 136, - tariffName: 'Посылка склад-склад', - weightGrams: 500, - }), - ); - - route(HttpMethod.Post, '/api/v1/shop/orders', (request) => { - const idempotencyKey = request.headers.get('idempotency-key'); - const previous = idempotencyKey ? state.shopIdempotency.get(idempotencyKey) : undefined; - if (previous) return jsonResponse(previous); - - const input = objectBody(request) as unknown as CreateShopOrderInput; - const items = orderItems(input, state); - const itemsTotal = items.reduce((sum, item) => sum + item.sum, 0); - const number = state.nextShopOrderNumber(); - const userId = state.authUser(request)?.profile.id; - const pass = userId ? undefined : `order-pass-${number}`; - const value: ShopOrder = { - number, - status: 'new', - createdAt: state.now(), - payment: { pending: false }, - items, - itemsTotal, - shipping: 500, - total: itemsTotal + 500, - delivery: { - city: input.recipient.city, - address: input.recipient.address, - point: input.recipient.deliveryPoint || null, - }, - comment: input.recipient.comment || null, - track: null, - support: { email: 'shop@example.test' }, - }; - state.shopOrders.set(number, { - value, - email: input.recipient.email.trim().toLowerCase(), - ...(userId ? { userId } : {}), - ...(pass ? { accessToken: pass } : {}), - }); - const result = { number, ...(pass ? { pass } : {}) }; - if (idempotencyKey) state.shopIdempotency.set(idempotencyKey, result); - return jsonResponse(result); - }); - - route(HttpMethod.Get, '/api/v1/shop/orders/my', (request) => { - const userId = state.authUser(request)?.profile.id; - const email = emailAccess(request, state); - const token = request.headers.get('x-order-token'); - const hasOrderPass = - token !== null && [...state.shopOrders.values()].some((order) => order.accessToken === token); - if (!userId && !email && !hasOrderPass) { - return apiErrorResponse(401, 'UNAUTHORIZED', 'Нужна авторизация'); - } - const items = [...state.shopOrders.values()] - .filter( - (order) => - (userId !== undefined && order.userId === userId) || - (email !== undefined && order.email === email) || - (token !== null && order.accessToken === token), - ) - .map((order) => orderSummary(order.value)); - return jsonResponse({ items }); - }); - - route(HttpMethod.Post, '/api/v1/shop/orders/lookup/request', () => jsonResponse({ sent: true })); - route(HttpMethod.Post, '/api/v1/shop/orders/lookup/verify', (request) => { - const { email, code } = objectBody(request); - if (typeof email !== 'string' || code !== '123456') { - return apiErrorResponse(400, 'INVALID_CODE', 'Неверный код'); - } - const normalized = email.trim().toLowerCase(); - const token = `shop-access-${normalized}-${state.clock.now()}`; - const expiresInSec = 600; - state.shopAccess.set(token, { - email: normalized, - expiresAt: state.clock.now() + expiresInSec * 1_000, - }); - return jsonResponse({ token, expiresInSec }); - }); - - route(HttpMethod.Get, '/api/v1/shop/orders/:orderNumber', (request) => { - const order = state.shopOrders.get(request.params.orderNumber ?? ''); - if (!order) return jsonResponse(null); - return canOpen(request, order, state) - ? jsonResponse(order.value) - : apiErrorResponse(401, 'UNAUTHORIZED', 'Нет доступа к заказу'); - }); - route(HttpMethod.Post, '/api/v1/shop/orders/:orderNumber/pay', (request) => { - const order = state.shopOrders.get(request.params.orderNumber ?? ''); - if (!order) return apiErrorResponse(404, 'ORDER_NOT_FOUND', 'Заказ не найден'); - if (!canOpen(request, order, state)) { - return apiErrorResponse(401, 'UNAUTHORIZED', 'Нет доступа к заказу'); - } - return jsonResponse({ url: `https://payment.example.test/${order.value.number}` }); - }); - route(HttpMethod.Post, '/api/v1/shop/consents', () => emptyResponse()); -} diff --git a/packages/testing/src/server/routes/users.ts b/packages/testing/src/server/routes/users.ts deleted file mode 100644 index 6957a66..0000000 --- a/packages/testing/src/server/routes/users.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { NotificationType } from 'itd-api'; -import { HttpMethod } from '../../constants.js'; -import { apiErrorResponse, apiResponse, emptyResponse } from '../../responses.js'; -import { type MockRouteContext, objectBody } from './context.js'; - -export function registerUserRoutes({ state, route, requireAuth }: MockRouteContext): void { - route(HttpMethod.Get, '/api/profile', (request) => { - const user = state.authUser(request); - return apiResponse({ - authenticated: user !== undefined, - banned: false, - user: user - ? { - id: user.profile.id, - username: user.profile.username, - displayName: user.profile.displayName, - avatar: user.profile.avatar, - bio: user.profile.bio, - verified: user.profile.verified, - isPhoneVerified: user.profile.isPhoneVerified, - roles: ['user'], - } - : null, - }); - }); - - route( - HttpMethod.Get, - '/api/users/me', - requireAuth((_request, user) => apiResponse(state.myProfile(user))), - ); - - route( - HttpMethod.Put, - '/api/users/me', - requireAuth((request, user) => { - const body = objectBody(request); - for (const key of ['username', 'displayName', 'avatar', 'bio'] as const) { - if (typeof body[key] === 'string') user.profile[key] = body[key]; - } - if (body.bannerId === null) user.profile.banner = null; - return apiResponse(state.myProfile(user)); - }), - ); - - route( - HttpMethod.Delete, - '/api/users/me', - requireAuth((_request, user) => { - user.deactivated = true; - return emptyResponse(); - }), - ); - - route( - HttpMethod.Post, - '/api/users/me/restore', - requireAuth((_request, user) => { - user.deactivated = false; - return emptyResponse(); - }), - ); - - route( - HttpMethod.Get, - '/api/users/:user', - requireAuth((request, viewer) => { - const user = state.findUser(request.params.user ?? ''); - return user - ? apiResponse(state.publicProfile(viewer, user)) - : apiErrorResponse(404, 'USER_NOT_FOUND', 'Пользователь не найден'); - }), - ); - - route( - HttpMethod.Post, - '/api/users/:user/follow', - requireAuth((request, viewer) => { - const target = state.findUser(request.params.user ?? ''); - if (!target) return apiErrorResponse(404, 'USER_NOT_FOUND', 'Пользователь не найден'); - if (target.profile.id === viewer.profile.id) { - return apiErrorResponse(400, 'CANNOT_FOLLOW_SELF', 'Нельзя подписаться на себя'); - } - viewer.following.add(target.profile.id); - state.pushNotification(target.profile.id, NotificationType.Follow, viewer, viewer.profile.id); - return apiResponse({ - following: true, - followersCount: state.followersOf(target.profile.id), - }); - }), - ); - - route( - HttpMethod.Delete, - '/api/users/:user/follow', - requireAuth((request, viewer) => { - const target = state.findUser(request.params.user ?? ''); - if (!target) return apiErrorResponse(404, 'USER_NOT_FOUND', 'Пользователь не найден'); - viewer.following.delete(target.profile.id); - return emptyResponse(); - }), - ); -} diff --git a/packages/testing/src/server/search/search.presenter.ts b/packages/testing/src/server/search/search.presenter.ts new file mode 100644 index 0000000..01a7e16 --- /dev/null +++ b/packages/testing/src/server/search/search.presenter.ts @@ -0,0 +1,18 @@ +import type { UserPresenter } from '../users/users.presenter.js'; +import type { SearchResponse, SearchResults } from './search.types.js'; + +/** Результат поиска в форме ответа API. @internal */ +export class SearchPresenter { + readonly #users: UserPresenter; + + constructor(users: UserPresenter) { + this.#users = users; + } + + results(results: SearchResults): SearchResponse { + return { + users: results.users.map((user) => this.#users.searchUser(user)), + hashtags: results.hashtags, + }; + } +} diff --git a/packages/testing/src/server/search/search.routes.ts b/packages/testing/src/server/search/search.routes.ts new file mode 100644 index 0000000..cbb2323 --- /dev/null +++ b/packages/testing/src/server/search/search.routes.ts @@ -0,0 +1,10 @@ +import { HttpMethod } from '../../http/http.constants.js'; +import { apiResponse } from '../../http/responses.utils.js'; +import type { MockRouteContext } from '../shared/route.types.js'; + +export function registerSearchRoutes({ services, presenters, route }: MockRouteContext): void { + route(HttpMethod.Get, '/api/search', (request) => { + const results = services.search.search(request.query.get('q') ?? ''); + return apiResponse(presenters.search.results(results)); + }); +} diff --git a/packages/testing/src/server/search/search.service.ts b/packages/testing/src/server/search/search.service.ts new file mode 100644 index 0000000..b46d371 --- /dev/null +++ b/packages/testing/src/server/search/search.service.ts @@ -0,0 +1,27 @@ +import type { HashtagService } from '../hashtags/hashtags.service.js'; +import type { UserService } from '../users/users.service.js'; +import type { SearchResults } from './search.types.js'; + +/** Как и прод, поиск отдаёт не больше пяти пользователей и пяти хэштегов. */ +const SEARCH_LIMIT = 5; + +/** Глобальный поиск по пользователям и хэштегам. @internal */ +export class SearchService { + readonly #users: UserService; + readonly #hashtags: HashtagService; + + constructor(users: UserService, hashtags: HashtagService) { + this.#users = users; + this.#hashtags = hashtags; + } + + /** Решётка в начале и пробелы по краям запроса не учитываются. */ + search(query: string): SearchResults { + const text = query.trim().replace(/^#/, ''); + if (!text) return { users: [], hashtags: [] }; + return { + users: this.#users.search(text, SEARCH_LIMIT), + hashtags: this.#hashtags.searchByPrefix(text, SEARCH_LIMIT), + }; + } +} diff --git a/packages/testing/src/server/search/search.types.ts b/packages/testing/src/server/search/search.types.ts new file mode 100644 index 0000000..319cc3c --- /dev/null +++ b/packages/testing/src/server/search/search.types.ts @@ -0,0 +1,14 @@ +import type { Hashtag, UserSummary } from 'itd-api'; +import type { UserRecord } from '../users/users.types.js'; + +/** Результат глобального поиска до сборки ответа. @internal */ +export interface SearchResults { + users: UserRecord[]; + hashtags: Hashtag[]; +} + +/** Результат глобального поиска в форме ответа API. @internal */ +export interface SearchResponse { + users: UserSummary[]; + hashtags: Hashtag[]; +} diff --git a/packages/testing/src/server/server.runtime.ts b/packages/testing/src/server/server.runtime.ts new file mode 100644 index 0000000..4d36d8f --- /dev/null +++ b/packages/testing/src/server/server.runtime.ts @@ -0,0 +1,67 @@ +import type { ItdClock } from 'itd-api'; +import { CommentPresenter } from './comments/comments.presenter.js'; +import { CommentService } from './comments/comments.service.js'; +import { HashtagPresenter } from './hashtags/hashtags.presenter.js'; +import { HashtagService } from './hashtags/hashtags.service.js'; +import { NotificationDelivery } from './notifications/notification.delivery.js'; +import { NotificationPresenter } from './notifications/notifications.presenter.js'; +import { NotificationService } from './notifications/notifications.service.js'; +import { PostPresenter } from './posts/posts.presenter.js'; +import { PostService } from './posts/posts.service.js'; +import { SearchPresenter } from './search/search.presenter.js'; +import { SearchService } from './search/search.service.js'; +import type { + MockPresenters, + MockServerSeed, + MockServerSnapshot, + MockServices, +} from './server.types.js'; +import { ShopService } from './shop/shop.service.js'; +import { MockStore } from './store/mock.store.js'; +import { buildStoreContents } from './store/seed.builder.js'; +import { createSnapshot } from './store/snapshot.builder.js'; +import { UserPresenter } from './users/users.presenter.js'; +import { UserService } from './users/users.service.js'; + +/** Хранилище, сервисы и presenters одного mock-server, связанные через конструкторы. @internal */ +export class MockServerRuntime { + readonly store: MockStore; + readonly services: MockServices; + readonly presenters: MockPresenters; + readonly delivery: NotificationDelivery; + + constructor(clock: ItdClock) { + const store = new MockStore(clock); + const notifications = new NotificationService(store); + const users = new UserService(store, notifications); + const posts = new PostService(store, users, notifications); + const comments = new CommentService(store, users, notifications); + const hashtags = new HashtagService(posts); + const search = new SearchService(users, hashtags); + const shop = new ShopService(store); + const userPresenter = new UserPresenter(users, posts); + const notificationPresenter = new NotificationPresenter(users, userPresenter); + + this.store = store; + this.services = { users, posts, comments, notifications, hashtags, search, shop }; + const postPresenter = new PostPresenter(users, posts, comments, userPresenter); + this.presenters = { + users: userPresenter, + posts: postPresenter, + comments: new CommentPresenter(users, comments, userPresenter), + notifications: notificationPresenter, + hashtags: new HashtagPresenter(postPresenter), + search: new SearchPresenter(userPresenter), + }; + this.delivery = new NotificationDelivery(notifications, notificationPresenter); + } + + /** Валидирует seed полностью и заменяет данные только после успешной сборки. */ + load(seed: MockServerSeed | undefined): void { + this.store.load(buildStoreContents(seed, () => this.store.now())); + } + + snapshot(): MockServerSnapshot { + return createSnapshot(this.store, this.presenters.users); + } +} diff --git a/packages/testing/src/server/contracts.ts b/packages/testing/src/server/server.types.ts similarity index 59% rename from packages/testing/src/server/contracts.ts rename to packages/testing/src/server/server.types.ts index 4327210..0736bb9 100644 --- a/packages/testing/src/server/contracts.ts +++ b/packages/testing/src/server/server.types.ts @@ -5,16 +5,32 @@ import type { Notification, ShopOrder, ShopProduct, + Span, } from 'itd-api'; -import type { MockEventTransport } from '../events.js'; -import type { RecordedRequest } from '../request.js'; -import type { MockHandler } from '../router.js'; +import type { MockEventTransport } from '../events/mock-event.transport.js'; +import type { RecordedRequest } from '../http/request.types.js'; +import type { MockHandler } from '../http/router.types.js'; +import type { CommentPresenter } from './comments/comments.presenter.js'; +import type { CommentService } from './comments/comments.service.js'; +import type { HashtagPresenter } from './hashtags/hashtags.presenter.js'; +import type { HashtagService } from './hashtags/hashtags.service.js'; +import type { NotificationPresenter } from './notifications/notifications.presenter.js'; +import type { NotificationService } from './notifications/notifications.service.js'; +import type { PostPresenter } from './posts/posts.presenter.js'; +import type { PostService } from './posts/posts.service.js'; +import type { SearchPresenter } from './search/search.presenter.js'; +import type { SearchService } from './search/search.service.js'; +import type { ShopService } from './shop/shop.service.js'; +import type { UserPresenter } from './users/users.presenter.js'; +import type { UserService } from './users/users.service.js'; export interface MockUserSeed { id?: string; username?: string; displayName?: string; avatar?: string; + /** Эмодзи клана. По умолчанию — `avatar`, если он не адрес изображения. */ + clanAvatar?: string; banner?: string | null; bio?: string; verified?: boolean; @@ -32,6 +48,9 @@ export interface MockPostSeed { id?: string; authorId: string; content?: string; + spans?: readonly Span[]; + /** Пост, который репостнули. Репост репоста ссылается на репост, а не на начало цепочки. */ + originalPostId?: string | null; wallRecipientId?: string | null; createdAt?: string; likedBy?: readonly string[]; @@ -112,6 +131,8 @@ export interface MockPostSnapshot { readonly id: string; readonly authorId: string; readonly content: string; + readonly spans: readonly Readonly[]; + readonly originalPostId: string | null; readonly wallRecipientId: string | null; readonly createdAt: string; readonly editedAt: string | null; @@ -132,14 +153,22 @@ export interface MockCommentSnapshot { readonly deleted: boolean; } -/** Уведомление из снимка сервера. */ -export type MockNotificationSnapshot = Readonly> & { - readonly actors: readonly Readonly[]; +/** Уведомление из снимка сервера. Участники и получатель представлены идентификаторами. */ +export interface MockNotificationSnapshot { + readonly id: string; readonly userId: string; -}; + readonly type: Notification['type']; + readonly actorIds: readonly string[]; + readonly entityId: string | null; + readonly parentEntityId: string | null; + readonly preview: string | null; + readonly isRead: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} export interface MockServerClientOptions { - /** Идентификатор или имя пользователя из seed. */ + /** Идентификатор пользователя или его текущее имя. */ as: string; } @@ -157,3 +186,24 @@ export interface MockServer { assertNoUnsupportedRequests(): void; clearRequests(): void; } + +/** Доменные сервисы mock-server. @internal */ +export interface MockServices { + readonly users: UserService; + readonly posts: PostService; + readonly comments: CommentService; + readonly notifications: NotificationService; + readonly hashtags: HashtagService; + readonly search: SearchService; + readonly shop: ShopService; +} + +/** Сборщики моделей ответов API. @internal */ +export interface MockPresenters { + readonly users: UserPresenter; + readonly posts: PostPresenter; + readonly comments: CommentPresenter; + readonly notifications: NotificationPresenter; + readonly hashtags: HashtagPresenter; + readonly search: SearchPresenter; +} diff --git a/packages/testing/src/server/shared/access.types.ts b/packages/testing/src/server/shared/access.types.ts new file mode 100644 index 0000000..baf8df7 --- /dev/null +++ b/packages/testing/src/server/shared/access.types.ts @@ -0,0 +1,9 @@ +/** Отношение владельца ресурса к тому, кто к нему обращается. @internal */ +export interface AccessRelation { + /** Обращается сам владелец. */ + isOwner: boolean; + /** Обращающийся подписан на владельца. */ + follows: boolean; + /** Владелец подписан на обращающегося. */ + followedBy: boolean; +} diff --git a/packages/testing/src/server/shared/access.utils.ts b/packages/testing/src/server/shared/access.utils.ts new file mode 100644 index 0000000..54ed24b --- /dev/null +++ b/packages/testing/src/server/shared/access.utils.ts @@ -0,0 +1,20 @@ +import { AccessType } from 'itd-api'; +import type { AccessRelation } from './access.types.js'; + +/** + * Разрешает ли политика доступа (`likesVisibility`, `wallAccess`) обращение. Владельцу доступ + * открыт всегда, неизвестная политика доступ закрывает. @internal + */ +export function canAccess(policy: AccessType, relation: AccessRelation): boolean { + if (relation.isOwner) return true; + switch (policy) { + case AccessType.Everyone: + return true; + case AccessType.Followers: + return relation.follows; + case AccessType.Mutual: + return relation.follows && relation.followedBy; + default: + return false; + } +} diff --git a/packages/testing/src/server/shared/domain.constants.ts b/packages/testing/src/server/shared/domain.constants.ts new file mode 100644 index 0000000..bc2aee5 --- /dev/null +++ b/packages/testing/src/server/shared/domain.constants.ts @@ -0,0 +1,9 @@ +/** Коды ошибок mock-server, которых нет в `ItdErrorCode`. @internal */ +export const MockErrorCode = Object.freeze({ + Forbidden: 'FORBIDDEN', + CannotFollowSelf: 'CANNOT_FOLLOW_SELF', + InvalidCode: 'INVALID_CODE', + OrderNotFound: 'ORDER_NOT_FOUND', + RouteNotImplemented: 'MOCK_ROUTE_NOT_IMPLEMENTED', +} as const); +export type MockErrorCode = (typeof MockErrorCode)[keyof typeof MockErrorCode]; diff --git a/packages/testing/src/server/shared/domain.errors.ts b/packages/testing/src/server/shared/domain.errors.ts new file mode 100644 index 0000000..d93a4af --- /dev/null +++ b/packages/testing/src/server/shared/domain.errors.ts @@ -0,0 +1,36 @@ +import { ItdErrorCode } from 'itd-api'; +import { MockErrorCode } from './domain.constants.js'; +import type { KnownItdErrorCode } from './domain.types.js'; + +/** Нарушение доменного правила mock-server с HTTP-статусом и кодом ошибки API. @internal */ +export class MockDomainError extends Error { + readonly status: number; + readonly code: KnownItdErrorCode | MockErrorCode; + + constructor(status: number, code: KnownItdErrorCode | MockErrorCode, message: string) { + super(message); + this.name = 'MockDomainError'; + this.status = status; + this.code = code; + } + + static notFound(message: string): MockDomainError { + return new MockDomainError(404, ItdErrorCode.NOT_FOUND, message); + } + + static forbidden(message: string): MockDomainError { + return new MockDomainError(403, MockErrorCode.Forbidden, message); + } + + static unauthorized(message: string): MockDomainError { + return new MockDomainError(401, ItdErrorCode.UNAUTHORIZED, message); + } + + static validation(message: string): MockDomainError { + return new MockDomainError(400, ItdErrorCode.VALIDATION_ERROR, message); + } + + static badRequest(code: KnownItdErrorCode | MockErrorCode, message: string): MockDomainError { + return new MockDomainError(400, code, message); + } +} diff --git a/packages/testing/src/server/shared/domain.types.ts b/packages/testing/src/server/shared/domain.types.ts new file mode 100644 index 0000000..afa3f47 --- /dev/null +++ b/packages/testing/src/server/shared/domain.types.ts @@ -0,0 +1,4 @@ +import type { ItdErrorCode } from 'itd-api'; + +/** Известные SDK коды ошибок без открытого расширения `Loose`. @internal */ +export type KnownItdErrorCode = (typeof ItdErrorCode)[keyof typeof ItdErrorCode]; diff --git a/packages/testing/src/server/shared/numbers.utils.ts b/packages/testing/src/server/shared/numbers.utils.ts new file mode 100644 index 0000000..f5fb469 --- /dev/null +++ b/packages/testing/src/server/shared/numbers.utils.ts @@ -0,0 +1,14 @@ +/** Положительное целое из строки, ограниченное сверху; иначе `fallback`. @internal */ +export function positiveInt( + value: string | null | undefined, + fallback: number, + maximum = 100, +): number { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; +} + +/** Неотрицательное целое из строки; иначе `0`. @internal */ +export function nonNegativeInt(value: string | null | undefined): number { + return Math.max(0, Number.parseInt(value ?? '0', 10) || 0); +} diff --git a/packages/testing/src/server/shared/pagination.types.ts b/packages/testing/src/server/shared/pagination.types.ts new file mode 100644 index 0000000..cd09938 --- /dev/null +++ b/packages/testing/src/server/shared/pagination.types.ts @@ -0,0 +1,55 @@ +/** Страница курсорного списка. Курсор — смещение следующей записи в виде строки. @internal */ +export interface CursorSlice { + items: T[]; + nextCursor: string | null; + hasMore: boolean; + limit: number; +} + +/** Страница списка со смещением. @internal */ +export interface OffsetSlice { + items: T[]; + hasMore: boolean; +} + +/** Страница списка с номером страницы. @internal */ +export interface NumberedSlice { + items: T[]; + page: number; + limit: number; + total: number; + hasMore: boolean; +} + +/** Какую курсорную страницу запросили. @internal */ +export interface CursorQuery { + limit: number; + cursor?: string | null | undefined; +} + +/** Какую страницу списка со смещением запросили. @internal */ +export interface OffsetQuery { + limit: number; + offset: number; +} + +/** Какую страницу списка с номером страницы запросили. Страницы нумеруются с 1. @internal */ +export interface NumberedQuery { + limit: number; + page: number; +} + +/** Поле `pagination` курсорного ответа API. @internal */ +export interface CursorPagination { + hasMore: boolean; + nextCursor: string | null; + limit: number; +} + +/** Поле `pagination` ответа API со страницами по номеру. @internal */ +export interface NumberedPagination { + page: number; + limit: number; + total: number; + hasMore: boolean; +} diff --git a/packages/testing/src/server/shared/pagination.utils.ts b/packages/testing/src/server/shared/pagination.utils.ts new file mode 100644 index 0000000..b004448 --- /dev/null +++ b/packages/testing/src/server/shared/pagination.utils.ts @@ -0,0 +1,55 @@ +import { nonNegativeInt } from './numbers.utils.js'; +import type { + CursorPagination, + CursorQuery, + CursorSlice, + NumberedPagination, + NumberedQuery, + NumberedSlice, + OffsetQuery, + OffsetSlice, +} from './pagination.types.js'; + +/** @internal */ +export function cursorPage(items: readonly T[], options: CursorQuery): CursorSlice { + const offset = nonNegativeInt(options.cursor); + const page = items.slice(offset, offset + options.limit); + const nextOffset = offset + page.length; + const hasMore = nextOffset < items.length; + return { + items: page, + nextCursor: hasMore ? String(nextOffset) : null, + hasMore, + limit: options.limit, + }; +} + +/** @internal */ +export function offsetPage(items: readonly T[], options: OffsetQuery): OffsetSlice { + return { + items: items.slice(options.offset, options.offset + options.limit), + hasMore: options.offset + options.limit < items.length, + }; +} + +/** Страницы нумеруются с 1. @internal */ +export function numberedPage(items: readonly T[], options: NumberedQuery): NumberedSlice { + const start = (options.page - 1) * options.limit; + return { + items: items.slice(start, start + options.limit), + page: options.page, + limit: options.limit, + total: items.length, + hasMore: start + options.limit < items.length, + }; +} + +/** Поле `pagination` курсорного ответа API. @internal */ +export function cursorPagination(slice: CursorSlice): CursorPagination { + return { hasMore: slice.hasMore, nextCursor: slice.nextCursor, limit: slice.limit }; +} + +/** Поле `pagination` ответа API со страницами по номеру. @internal */ +export function numberedPagination(slice: NumberedSlice): NumberedPagination { + return { page: slice.page, limit: slice.limit, total: slice.total, hasMore: slice.hasMore }; +} diff --git a/packages/testing/src/server/shared/request.utils.ts b/packages/testing/src/server/shared/request.utils.ts new file mode 100644 index 0000000..8d81ac0 --- /dev/null +++ b/packages/testing/src/server/shared/request.utils.ts @@ -0,0 +1,71 @@ +import type { Span } from 'itd-api'; +import type { MockRequest } from '../../http/request.types.js'; +import { MockDomainError } from './domain.errors.js'; +import { nonNegativeInt, positiveInt } from './numbers.utils.js'; +import type { CursorQuery, NumberedQuery, OffsetQuery } from './pagination.types.js'; + +/** Обычный объект, не массив и не `null`. @internal */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** JSON-тело запроса как объект; всё остальное — пустой объект. @internal */ +export function objectBody(request: MockRequest): Record { + return isRecord(request.json) ? request.json : {}; +} + +/** Строковое поле тела. Отсутствующее или `null` — `undefined`, значение другого типа — ошибка. @internal */ +export function stringField(body: Record, key: string): string | undefined { + const value = body[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') throw MockDomainError.validation(`${key}: ожидается строка`); + return value; +} + +function isSpan(value: unknown): value is Span { + if (typeof value !== 'object' || value === null) return false; + const { type, offset, length } = value as Record; + return ( + typeof type === 'string' && + Number.isInteger(offset) && + (offset as number) >= 0 && + Number.isInteger(length) && + (length as number) > 0 + ); +} + +/** Разметка из тела запроса. Отсутствующее поле — `undefined`, неверная разметка — ошибка. @internal */ +export function spansField(body: Record): Span[] | undefined { + const value = body.spans; + if (value === undefined) return undefined; + if (!Array.isArray(value) || !value.every(isSpan)) { + throw MockDomainError.validation('spans: ожидается массив фрагментов разметки'); + } + return structuredClone(value); +} + +/** Размер страницы из `limit`: по умолчанию 20, не больше `maximum`. */ +function pageLimit(request: MockRequest, maximum: number): number { + return positiveInt(request.query.get('limit'), 20, maximum); +} + +/** Курсорная страница из `limit` и `cursor`. @internal */ +export function cursorQuery(request: MockRequest, maxLimit = 100): CursorQuery { + return { limit: pageLimit(request, maxLimit), cursor: request.query.get('cursor') }; +} + +/** Страница со смещением из `limit` и `offset`. @internal */ +export function offsetQuery(request: MockRequest, maxLimit = 100): OffsetQuery { + return { + limit: pageLimit(request, maxLimit), + offset: nonNegativeInt(request.query.get('offset')), + }; +} + +/** Страница по номеру из `limit` и `page`. @internal */ +export function numberedQuery(request: MockRequest, maxLimit = 100): NumberedQuery { + return { + limit: pageLimit(request, maxLimit), + page: positiveInt(request.query.get('page'), 1, Number.MAX_SAFE_INTEGER), + }; +} diff --git a/packages/testing/src/server/shared/route.context.ts b/packages/testing/src/server/shared/route.context.ts new file mode 100644 index 0000000..ca78803 --- /dev/null +++ b/packages/testing/src/server/shared/route.context.ts @@ -0,0 +1,48 @@ +import { ItdErrorCode } from 'itd-api'; +import { HttpMethod } from '../../http/http.constants.js'; +import type { MockRequest } from '../../http/request.types.js'; +import { apiErrorResponse } from '../../http/responses.utils.js'; +import type { MockHandler } from '../../http/router.types.js'; +import { compileRoute, defineRoute } from '../../http/router.utils.js'; +import { MockDomainError } from './domain.errors.js'; +import type { MockRouteContext, RegisteredHandler } from './route.types.js'; + +async function translateDomainErrors(handler: MockHandler, request: MockRequest) { + try { + return await handler(request); + } catch (error) { + if (error instanceof MockDomainError) { + return apiErrorResponse(error.status, error.code, error.message); + } + throw error; + } +} + +/** @internal */ +export function createRouteContext( + dependencies: Pick, + routes: RegisteredHandler[], +): MockRouteContext { + return { + ...dependencies, + route(method, path, handler) { + const descriptor = defineRoute(method, path); + routes.push({ + route: descriptor, + compiled: compileRoute(descriptor), + handler: (request) => translateDomainErrors(handler, request), + }); + }, + requireAuth(handler, options = {}) { + return (request) => { + const user = dependencies.auth.authenticate(request); + if (!user) return apiErrorResponse(401, ItdErrorCode.UNAUTHORIZED, 'Нужна авторизация'); + const reading = request.method.toUpperCase() === HttpMethod.Get; + if (user.deactivated && !reading && !options.allowDeactivated) { + return apiErrorResponse(403, ItdErrorCode.ACCOUNT_DEACTIVATED, 'Аккаунт деактивирован'); + } + return handler(request, user); + }; + }, + }; +} diff --git a/packages/testing/src/server/shared/route.types.ts b/packages/testing/src/server/shared/route.types.ts new file mode 100644 index 0000000..afd8dd3 --- /dev/null +++ b/packages/testing/src/server/shared/route.types.ts @@ -0,0 +1,37 @@ +import type { MockRequest } from '../../http/request.types.js'; +import type { CompiledRoute, MockHandler, MockRoute } from '../../http/router.types.js'; +import type { MockPresenters, MockServices } from '../server.types.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { TokenAuthenticator } from './token.authenticator.js'; + +/** Маршрут вместе с разобранным шаблоном пути и обработчиком. @internal */ +export interface RegisteredHandler { + route: MockRoute; + compiled: CompiledRoute; + handler: MockHandler; +} + +/** Обработчик маршрута, которому уже известен авторизованный пользователь. @internal */ +export type AuthenticatedHandler = ( + request: MockRequest, + user: UserRecord, +) => Response | Promise; + +/** Всё, что нужно модулю маршрутов: сервисы, presenters и регистрация обработчиков. @internal */ +export interface MockRouteContext { + readonly services: MockServices; + readonly presenters: MockPresenters; + readonly auth: TokenAuthenticator; + route(method: string, path: string, handler: MockHandler): void; + /** + * Пропускает к обработчику только авторизованного пользователя. Деактивированному доступны + * чтение и действия с `allowDeactivated`; остальное отклоняется `403 ACCOUNT_DEACTIVATED`. + */ + requireAuth(handler: AuthenticatedHandler, options?: RequireAuthOptions): MockHandler; +} + +/** @internal */ +export interface RequireAuthOptions { + /** Действие доступно деактивированному пользователю, например восстановление аккаунта. */ + allowDeactivated?: boolean; +} diff --git a/packages/testing/src/server/shared/token.authenticator.ts b/packages/testing/src/server/shared/token.authenticator.ts new file mode 100644 index 0000000..415843b --- /dev/null +++ b/packages/testing/src/server/shared/token.authenticator.ts @@ -0,0 +1,33 @@ +import type { MockRequest } from '../../http/request.types.js'; +import type { UserService } from '../users/users.service.js'; +import type { UserRecord } from '../users/users.types.js'; + +function decodeTokenPayload(token: string): Record | undefined { + try { + const segment = token.split('.')[1]; + if (!segment) return undefined; + const normalized = segment + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(segment.length / 4) * 4, '='); + return JSON.parse(atob(normalized)) as Record; + } catch { + return undefined; + } +} + +/** Определяет пользователя по `sub` тестового JWT из заголовка `Authorization`. @internal */ +export class TokenAuthenticator { + readonly #users: UserService; + + constructor(users: UserService) { + this.#users = users; + } + + authenticate(request: MockRequest): UserRecord | undefined { + const value = request.headers.get('authorization'); + if (!value?.startsWith('Bearer ')) return undefined; + const sub = decodeTokenPayload(value.slice(7))?.sub; + return typeof sub === 'string' ? this.#users.get(sub) : undefined; + } +} diff --git a/packages/testing/src/server/shop/shop.constants.ts b/packages/testing/src/server/shop/shop.constants.ts new file mode 100644 index 0000000..bd9c191 --- /dev/null +++ b/packages/testing/src/server/shop/shop.constants.ts @@ -0,0 +1,17 @@ +import type { ShopDeliveryCalculation, ShopDeliveryCountry } from 'itd-api'; + +/** Страны доставки mock-server. @internal */ +export const DELIVERY_COUNTRIES: readonly ShopDeliveryCountry[] = Object.freeze([ + { code: 'RU', name: 'Россия' }, +]); + +/** Расчёт доставки mock-server: одинаковый для любого адреса. @internal */ +export const DELIVERY_ESTIMATE: Readonly = Object.freeze({ + costKopecks: 50_000, + cost: 500, + periodMin: 2, + periodMax: 5, + tariffCode: 136, + tariffName: 'Посылка склад-склад', + weightGrams: 500, +}); diff --git a/packages/testing/src/server/shop/shop.presenter.ts b/packages/testing/src/server/shop/shop.presenter.ts new file mode 100644 index 0000000..82f4da7 --- /dev/null +++ b/packages/testing/src/server/shop/shop.presenter.ts @@ -0,0 +1,41 @@ +import type { ShopDeliveryCity, ShopDeliveryPoint, ShopOrder, ShopOrderSummary } from 'itd-api'; +import type { ShopOrderRecord } from './shop.types.js'; + +/** Краткая запись заказа для списка заказов. @internal */ +export function orderSummary(order: ShopOrder): ShopOrderSummary { + return { + number: order.number, + titles: order.items.map((item) => item.title), + status: order.status, + total: order.total, + createdAt: order.createdAt, + }; +} + +/** Список заказов покупателя. @internal */ +export function orderList(orders: readonly ShopOrderRecord[]): { items: ShopOrderSummary[] } { + return { items: orders.map((order) => orderSummary(order.value)) }; +} + +/** Город доставки с тем названием, которое искали. @internal */ +export function deliveryCity(name: string, countryCode: string): ShopDeliveryCity { + return { code: 44, name, countryCode }; +} + +/** Единственный пункт выдачи mock-server в указанном городе. @internal */ +export function deliveryPoint(cityCode: number): ShopDeliveryPoint { + return { + code: 'PVZ-1', + name: 'Пункт выдачи', + city: 'Москва', + cityCode, + countryCode: 'RU', + postalCode: '101000', + address: 'Тестовая улица, 1', + latitude: 55.75, + longitude: 37.62, + dressingRoom: true, + card: true, + cash: false, + }; +} diff --git a/packages/testing/src/server/shop/shop.routes.ts b/packages/testing/src/server/shop/shop.routes.ts new file mode 100644 index 0000000..eabd872 --- /dev/null +++ b/packages/testing/src/server/shop/shop.routes.ts @@ -0,0 +1,108 @@ +import type { ShopOrderItemInput } from 'itd-api'; +import { HttpMethod } from '../../http/http.constants.js'; +import type { MockRequest } from '../../http/request.types.js'; +import { emptyResponse, jsonResponse } from '../../http/responses.utils.js'; +import { MockDomainError } from '../shared/domain.errors.js'; +import { isRecord, objectBody, stringField } from '../shared/request.utils.js'; +import type { MockRouteContext } from '../shared/route.types.js'; +import { DELIVERY_COUNTRIES, DELIVERY_ESTIMATE } from './shop.constants.js'; +import { deliveryCity, deliveryPoint, orderList } from './shop.presenter.js'; +import type { NewShopOrder, ShopCredentials } from './shop.types.js'; + +/** Строковое поле, без которого запрос не имеет смысла. */ +function requiredString(body: Record, key: string, label = key): string { + const value = stringField(body, key); + if (value === undefined) throw MockDomainError.validation(`${label}: ожидается строка`); + return value; +} + +function orderItem(value: unknown): ShopOrderItemInput { + if (!isRecord(value) || !Number.isInteger(value.qty) || (value.qty as number) <= 0) { + throw MockDomainError.validation('items: ожидается товар и положительное количество'); + } + return { + productId: requiredString(value, 'productId', 'items.productId'), + size: stringField(value, 'size') ?? null, + color: stringField(value, 'color') ?? null, + qty: value.qty as number, + }; +} + +/** Заказ из тела запроса: непустой список позиций и получатель с адресом. */ +function newOrder(body: Record): NewShopOrder { + const { items, recipient } = body; + if (!Array.isArray(items) || items.length === 0) { + throw MockDomainError.validation('items: ожидается непустой список позиций заказа'); + } + if (!isRecord(recipient)) throw MockDomainError.validation('recipient: ожидается получатель'); + return { + items: items.map(orderItem), + recipient: { + email: requiredString(recipient, 'email', 'recipient.email'), + city: requiredString(recipient, 'city', 'recipient.city'), + address: requiredString(recipient, 'address', 'recipient.address'), + deliveryPoint: stringField(recipient, 'deliveryPoint') || null, + comment: stringField(recipient, 'comment') || null, + }, + }; +} + +export function registerShopRoutes({ services, auth, route }: MockRouteContext): void { + const { shop } = services; + + const credentials = (request: MockRequest): ShopCredentials => ({ + userId: auth.authenticate(request)?.profile.id, + token: request.headers.get('x-order-token'), + }); + + route(HttpMethod.Get, '/api/v1/shop/products', () => jsonResponse(shop.products())); + route(HttpMethod.Get, '/api/v1/shop/products/:productId', (request) => + jsonResponse(shop.requireProduct(request.params.productId ?? '')), + ); + route(HttpMethod.Get, '/api/v1/shop/delivery/countries', () => jsonResponse(DELIVERY_COUNTRIES)); + route(HttpMethod.Get, '/api/v1/shop/delivery/cities', (request) => { + const query = request.query.get('q')?.trim() ?? ''; + const country = request.query.get('country') ?? 'RU'; + return jsonResponse(query ? [deliveryCity(query, country)] : []); + }); + route(HttpMethod.Get, '/api/v1/shop/delivery/points', (request) => + jsonResponse([deliveryPoint(Number(request.query.get('cityCode')))]), + ); + route(HttpMethod.Post, '/api/v1/shop/delivery/calculate', () => jsonResponse(DELIVERY_ESTIMATE)); + + route(HttpMethod.Post, '/api/v1/shop/orders', (request) => + jsonResponse( + shop.createOrder(newOrder(objectBody(request)), { + userId: auth.authenticate(request)?.profile.id, + idempotencyKey: request.headers.get('idempotency-key'), + }), + ), + ); + + route(HttpMethod.Get, '/api/v1/shop/orders/my', (request) => + jsonResponse(orderList(shop.ordersFor(credentials(request)))), + ); + + route(HttpMethod.Post, '/api/v1/shop/orders/lookup/request', () => jsonResponse({ sent: true })); + route(HttpMethod.Post, '/api/v1/shop/orders/lookup/verify', (request) => { + const body = objectBody(request); + return jsonResponse( + shop.grantAccess(requiredString(body, 'email'), stringField(body, 'code') ?? ''), + ); + }); + + route(HttpMethod.Get, '/api/v1/shop/orders/:orderNumber', (request) => { + const order = shop.findOrder(request.params.orderNumber ?? ''); + if (!order) return jsonResponse(null); + shop.assertAccess(order, credentials(request)); + return jsonResponse(order.value); + }); + route(HttpMethod.Post, '/api/v1/shop/orders/:orderNumber/pay', (request) => { + const order = shop.requireAccessibleOrder( + request.params.orderNumber ?? '', + credentials(request), + ); + return jsonResponse({ url: shop.paymentUrl(order) }); + }); + route(HttpMethod.Post, '/api/v1/shop/consents', () => emptyResponse()); +} diff --git a/packages/testing/src/server/shop/shop.service.ts b/packages/testing/src/server/shop/shop.service.ts new file mode 100644 index 0000000..8171626 --- /dev/null +++ b/packages/testing/src/server/shop/shop.service.ts @@ -0,0 +1,163 @@ +import { type ShopOrder, type ShopOrderItem, ShopOrderStatus, type ShopProduct } from 'itd-api'; +import { MockErrorCode } from '../shared/domain.constants.js'; +import { MockDomainError } from '../shared/domain.errors.js'; +import type { MockStore } from '../store/mock.store.js'; +import type { + NewShopOrder, + ShopAccessGrant, + ShopCredentials, + ShopOrderContext, + ShopOrderReceipt, + ShopOrderRecord, +} from './shop.types.js'; + +const ACCESS_CODE = '123456'; +const ACCESS_TTL_SEC = 600; +const SHIPPING = 500; + +/** Каталог, заказы и доступ к ним без аккаунта. @internal */ +export class ShopService { + readonly #store: MockStore; + + constructor(store: MockStore) { + this.#store = store; + } + + products(): ShopProduct[] { + return [...this.#store.shopProducts.values()]; + } + + requireProduct(productId: string): ShopProduct { + const product = this.#store.shopProducts.get(productId); + if (!product) throw MockDomainError.notFound('Товар не найден'); + return product; + } + + /** + * Создаёт заказ. Повтор с тем же ключом идемпотентности возвращает прежний результат, + * неизвестный товар — `404`. + */ + createOrder(input: NewShopOrder, options: ShopOrderContext): ShopOrderReceipt { + const previous = options.idempotencyKey + ? this.#store.shopIdempotency.get(options.idempotencyKey) + : undefined; + if (previous) return previous; + + const items = this.#orderItems(input); + const itemsTotal = items.reduce((sum, item) => sum + item.sum, 0); + const number = this.#store.nextShopOrderNumber(); + const pass = options.userId ? undefined : `order-pass-${number}`; + const value: ShopOrder = { + number, + status: ShopOrderStatus.New, + createdAt: this.#store.now(), + payment: { pending: false }, + items, + itemsTotal, + shipping: SHIPPING, + total: itemsTotal + SHIPPING, + delivery: { + city: input.recipient.city, + address: input.recipient.address, + point: input.recipient.deliveryPoint, + }, + comment: input.recipient.comment, + track: null, + support: { email: 'shop@example.test' }, + }; + this.#store.shopOrders.set(number, { + value, + email: input.recipient.email.trim().toLowerCase(), + ...(options.userId ? { userId: options.userId } : {}), + ...(pass ? { accessToken: pass } : {}), + }); + const receipt: ShopOrderReceipt = { number, ...(pass ? { pass } : {}) }; + if (options.idempotencyKey) this.#store.shopIdempotency.set(options.idempotencyKey, receipt); + return receipt; + } + + /** Обменивает код из письма на временный доступ ко всем заказам этого адреса. */ + grantAccess(email: string, code: string): ShopAccessGrant { + if (code !== ACCESS_CODE) { + throw MockDomainError.badRequest(MockErrorCode.InvalidCode, 'Неверный код'); + } + const normalized = email.trim().toLowerCase(); + const now = this.#store.clock.now(); + const token = `shop-access-${normalized}-${now}`; + this.#store.shopAccess.set(token, { + email: normalized, + expiresAt: now + ACCESS_TTL_SEC * 1_000, + }); + return { token, expiresInSec: ACCESS_TTL_SEC }; + } + + /** Заказы, доступные по любому из предъявленных способов. */ + ordersFor(credentials: ShopCredentials): ShopOrderRecord[] { + const email = this.#accessEmail(credentials.token); + const orders = [...this.#store.shopOrders.values()]; + const hasOrderPass = + !!credentials.token && orders.some((order) => order.accessToken === credentials.token); + if (!credentials.userId && !email && !hasOrderPass) { + throw MockDomainError.unauthorized('Нужна авторизация'); + } + return orders.filter((order) => this.#matches(order, credentials, email)); + } + + findOrder(number: string): ShopOrderRecord | undefined { + return this.#store.shopOrders.get(number); + } + + /** Заказ, к которому у покупателя есть доступ. */ + requireAccessibleOrder(number: string, credentials: ShopCredentials): ShopOrderRecord { + const order = this.findOrder(number); + if (!order) throw new MockDomainError(404, MockErrorCode.OrderNotFound, 'Заказ не найден'); + this.assertAccess(order, credentials); + return order; + } + + assertAccess(order: ShopOrderRecord, credentials: ShopCredentials): void { + if (!this.#matches(order, credentials, this.#accessEmail(credentials.token))) { + throw MockDomainError.unauthorized('Нет доступа к заказу'); + } + } + + paymentUrl(order: ShopOrderRecord): string { + return `https://payment.example.test/${order.value.number}`; + } + + #matches( + order: ShopOrderRecord, + credentials: ShopCredentials, + email: string | undefined, + ): boolean { + return ( + (credentials.userId !== undefined && order.userId === credentials.userId) || + (email !== undefined && order.email === email) || + (!!credentials.token && order.accessToken === credentials.token) + ); + } + + /** Адрес из действующего доступа по коду. Истёкший доступ удаляется. */ + #accessEmail(token: string | null | undefined): string | undefined { + if (!token) return undefined; + const access = this.#store.shopAccess.get(token); + if (!access) return undefined; + if (access.expiresAt > this.#store.clock.now()) return access.email; + this.#store.shopAccess.delete(token); + return undefined; + } + + #orderItems(input: NewShopOrder): ShopOrderItem[] { + return input.items.map((item) => { + const product = this.requireProduct(item.productId); + return { + slug: item.productId, + title: product.title, + color: item.color, + size: item.size, + qty: item.qty, + sum: product.price * item.qty, + }; + }); + } +} diff --git a/packages/testing/src/server/shop/shop.types.ts b/packages/testing/src/server/shop/shop.types.ts new file mode 100644 index 0000000..67faa2c --- /dev/null +++ b/packages/testing/src/server/shop/shop.types.ts @@ -0,0 +1,55 @@ +import type { ShopOrder, ShopOrderItemInput } from 'itd-api'; + +/** Заказ магазина в хранилище mock-server. @internal */ +export interface ShopOrderRecord { + value: ShopOrder; + userId?: string; + email: string; + accessToken?: string; +} + +/** Доступ к заказам по коду из письма. @internal */ +export interface ShopAccessRecord { + email: string; + expiresAt: number; +} + +/** Результат создания заказа, повторяемый по ключу идемпотентности. @internal */ +export interface ShopOrderReceipt { + number: string; + pass?: string; +} + +/** Чем покупатель подтверждает доступ к заказам. @internal */ +export interface ShopCredentials { + userId?: string | undefined; + /** Пропуск заказа или токен доступа по коду из письма. */ + token?: string | null | undefined; +} + +/** Выданный по коду из письма доступ. @internal */ +export interface ShopAccessGrant { + token: string; + expiresInSec: number; +} + +/** Получатель нового заказа: поля, которые учитывает магазин mock-server. @internal */ +export interface NewShopOrderRecipient { + email: string; + city: string; + address: string; + deliveryPoint: string | null; + comment: string | null; +} + +/** Новый заказ после разбора тела запроса. @internal */ +export interface NewShopOrder { + items: ShopOrderItemInput[]; + recipient: NewShopOrderRecipient; +} + +/** Кто оформляет заказ и с каким ключом идемпотентности. @internal */ +export interface ShopOrderContext { + userId?: string | undefined; + idempotencyKey?: string | null; +} diff --git a/packages/testing/src/server/state.ts b/packages/testing/src/server/state.ts deleted file mode 100644 index 66a6bf1..0000000 --- a/packages/testing/src/server/state.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { - Comment, - ItdClock, - MyProfile, - Notification, - Post, - PublicProfile, - ShopProduct, -} from 'itd-api'; -import { MockEventTransport } from '../events.js'; -import { - commentFixture, - notificationFixture, - postFixture, - publicProfileFixture, -} from '../fixtures.js'; -import type { MockRequest } from '../request.js'; -import type { MockServerSeed, MockServerSnapshot } from './contracts.js'; -import type { - CommentState, - NotificationState, - PostState, - ShopOrderState, - UserState, -} from './entities.js'; -import { buildMockServerSeed } from './seed.js'; - -function decodeToken(token: string): Record | undefined { - try { - const segment = token.split('.')[1]; - if (!segment) return undefined; - const normalized = segment - .replace(/-/g, '+') - .replace(/_/g, '/') - .padEnd(Math.ceil(segment.length / 4) * 4, '='); - return JSON.parse(atob(normalized)) as Record; - } catch { - return undefined; - } -} - -/** Изменяемое доменное состояние in-memory mock server. @internal */ -export class MockServerState { - users = new Map(); - posts = new Map(); - comments = new Map(); - notifications: NotificationState[] = []; - shopProducts = new Map(); - shopOrders = new Map(); - readonly shopAccess = new Map(); - readonly shopIdempotency = new Map(); - readonly eventTransports = new Map>(); - readonly clock: ItdClock; - #postSequence = 1; - #commentSequence = 1; - #notificationSequence = 1; - #shopOrderSequence = 1; - - constructor(clock: ItdClock) { - this.clock = clock; - } - - now(): string { - return new Date(this.clock.now()).toISOString(); - } - - findUser(reference: string): UserState | undefined { - return ( - this.users.get(reference) ?? - [...this.users.values()].find((item) => item.profile.username === reference) - ); - } - - actor(user: UserState): Notification['actors'][number] { - return { - id: user.profile.id, - username: user.profile.username, - displayName: user.profile.displayName, - avatar: user.profile.avatar, - }; - } - - activePostsBy(userId: string): number { - return [...this.posts.values()].filter((post) => !post.deleted && post.authorId === userId) - .length; - } - - followersOf(userId: string): number { - return [...this.users.values()].filter((user) => user.following.has(userId)).length; - } - - myProfile(user: UserState): MyProfile { - return { - ...user.profile, - followersCount: this.followersOf(user.profile.id), - followingCount: user.following.size, - postsCount: this.activePostsBy(user.profile.id), - subscription: { ...user.profile.subscription }, - }; - } - - publicProfile(viewer: UserState, user: UserState): PublicProfile { - return publicProfileFixture({ - ...user.profile, - followersCount: this.followersOf(user.profile.id), - followingCount: user.following.size, - postsCount: this.activePostsBy(user.profile.id), - isFollowing: viewer.following.has(user.profile.id), - isFollowedBy: user.following.has(viewer.profile.id), - }); - } - - postModel(value: PostState, viewer: UserState): Post { - const authorState = this.users.get(value.authorId); - if (!authorState) throw new Error(`У поста ${value.id} нет автора ${value.authorId}`); - return postFixture({ - id: value.id, - content: value.content, - author: this.actor(authorState), - wallRecipientId: value.wallRecipientId, - likesCount: value.likedBy.size, - commentsCount: [...this.comments.values()].filter( - (comment) => comment.postId === value.id && !comment.deleted, - ).length, - isLiked: value.likedBy.has(viewer.profile.id), - isOwner: value.authorId === viewer.profile.id, - editedAt: value.editedAt, - createdAt: value.createdAt, - }); - } - - commentModel(value: CommentState, viewer: UserState): Comment { - const authorState = this.users.get(value.authorId); - if (!authorState) throw new Error(`У комментария ${value.id} нет автора ${value.authorId}`); - const replyTo = value.replyToUserId ? this.users.get(value.replyToUserId) : undefined; - return commentFixture({ - id: value.id, - content: value.content, - author: this.actor(authorState), - likesCount: value.likedBy.size, - repliesCount: [...this.comments.values()].filter( - (item) => item.parentCommentId === value.id && !item.deleted, - ).length, - isLiked: value.likedBy.has(viewer.profile.id), - createdAt: value.createdAt, - ...(replyTo - ? { - replyTo: { - id: replyTo.profile.id, - username: replyTo.profile.username, - displayName: replyTo.profile.displayName, - }, - } - : {}), - }); - } - - unreadCount(userId: string): number { - return this.notifications.filter((item) => item.userId === userId && !item.value.isRead).length; - } - - pushNotification( - userId: string, - type: Notification['type'], - source: UserState, - entityId: string | null, - parentEntityId: string | null = null, - preview: string | null = null, - ): void { - if (userId === source.profile.id || !this.users.has(userId)) return; - const createdAt = this.now(); - let id: string; - do id = `notification-${this.#notificationSequence++}`; - while (this.notifications.some((item) => item.value.id === id)); - const value = notificationFixture({ - id, - type, - rawType: type, - actors: [this.actor(source)], - entityId, - parentEntityId, - preview, - createdAt, - updatedAt: createdAt, - }); - this.notifications.unshift({ userId, value }); - for (const transport of this.eventTransports.get(userId) ?? []) { - if (transport.connected) transport.notification(value, this.unreadCount(userId)); - } - } - - authUser(request: MockRequest): UserState | undefined { - const value = request.headers.get('authorization'); - if (!value?.startsWith('Bearer ')) return undefined; - const sub = decodeToken(value.slice(7))?.sub; - return typeof sub === 'string' ? this.users.get(sub) : undefined; - } - - nextPostId(): string { - let id: string; - do id = `post-${this.#postSequence++}`; - while (this.posts.has(id)); - return id; - } - - nextCommentId(): string { - let id: string; - do id = `comment-${this.#commentSequence++}`; - while (this.comments.has(id)); - return id; - } - - nextShopOrderNumber(): string { - let number: string; - do number = `SHOP-${String(this.#shopOrderSequence++).padStart(6, '0')}`; - while (this.shopOrders.has(number)); - return number; - } - - registerEventTransport(user: UserState): MockEventTransport { - const transport = new MockEventTransport(); - const transports = this.eventTransports.get(user.profile.id) ?? new Set(); - transports.add(transport); - this.eventTransports.set(user.profile.id, transports); - return transport; - } - - snapshot(): MockServerSnapshot { - return structuredClone({ - users: [...this.users.values()].map((user) => ({ - ...this.myProfile(user), - subscription: { ...user.profile.subscription }, - following: [...user.following].sort(), - deactivated: user.deactivated, - })), - posts: [...this.posts.values()].map((post) => ({ - ...post, - likedBy: [...post.likedBy].sort(), - })), - comments: [...this.comments.values()].map((comment) => ({ - ...comment, - likedBy: [...comment.likedBy].sort(), - })), - notifications: this.notifications.map((item) => ({ - ...item.value, - actors: item.value.actors.map((actor) => ({ ...actor })), - userId: item.userId, - })), - shopProducts: [...this.shopProducts.values()], - shopOrders: [...this.shopOrders.values()], - }); - } - - /** Валидирует seed полностью и заменяет состояние только после успешной сборки. */ - loadSeed(seed: MockServerSeed | undefined): void { - const next = buildMockServerSeed(seed, () => this.now()); - this.users = next.users; - this.posts = next.posts; - this.comments = next.comments; - this.notifications = next.notifications; - this.shopProducts = next.shopProducts; - this.shopOrders = next.shopOrders; - this.shopAccess.clear(); - this.shopIdempotency.clear(); - this.#postSequence = next.postSequence; - this.#commentSequence = next.commentSequence; - this.#notificationSequence = next.notificationSequence; - this.#shopOrderSequence = next.shopOrderSequence; - } -} diff --git a/packages/testing/src/server/store/mock.store.ts b/packages/testing/src/server/store/mock.store.ts new file mode 100644 index 0000000..b1bbed0 --- /dev/null +++ b/packages/testing/src/server/store/mock.store.ts @@ -0,0 +1,86 @@ +import type { ItdClock, ShopProduct } from 'itd-api'; +import type { CommentRecord } from '../comments/comments.types.js'; +import type { NotificationRecord } from '../notifications/notifications.types.js'; +import type { PostRecord } from '../posts/posts.types.js'; +import type { ShopAccessRecord, ShopOrderReceipt, ShopOrderRecord } from '../shop/shop.types.js'; +import type { UserRecord } from '../users/users.types.js'; +import type { MockStoreContents } from './store.types.js'; + +/** Сквозной генератор идентификаторов, пропускающий уже занятые значения. */ +class IdSequence { + #next: number; + readonly #format: (value: number) => string; + + constructor(format: (value: number) => string, start = 1) { + this.#format = format; + this.#next = start; + } + + reset(start: number): void { + this.#next = start; + } + + next(taken: (id: string) => boolean): string { + let id: string; + do id = this.#format(this.#next++); + while (taken(id)); + return id; + } +} + +/** Хранилище данных mock-server: коллекции записей, генераторы идентификаторов и часы. @internal */ +export class MockStore { + users = new Map(); + posts = new Map(); + comments = new Map(); + notifications: NotificationRecord[] = []; + shopProducts = new Map(); + shopOrders = new Map(); + readonly shopAccess = new Map(); + readonly shopIdempotency = new Map(); + readonly clock: ItdClock; + readonly #postIds = new IdSequence((value) => `post-${value}`); + readonly #commentIds = new IdSequence((value) => `comment-${value}`); + readonly #notificationIds = new IdSequence((value) => `notification-${value}`); + readonly #shopOrderNumbers = new IdSequence((value) => `SHOP-${String(value).padStart(6, '0')}`); + + constructor(clock: ItdClock) { + this.clock = clock; + } + + now(): string { + return new Date(this.clock.now()).toISOString(); + } + + nextPostId(): string { + return this.#postIds.next((id) => this.posts.has(id)); + } + + nextCommentId(): string { + return this.#commentIds.next((id) => this.comments.has(id)); + } + + nextNotificationId(): string { + return this.#notificationIds.next((id) => this.notifications.some((item) => item.id === id)); + } + + nextShopOrderNumber(): string { + return this.#shopOrderNumbers.next((number) => this.shopOrders.has(number)); + } + + /** Заменяет все данные. Временные доступы магазина при этом сбрасываются. */ + load(contents: MockStoreContents): void { + this.users = contents.users; + this.posts = contents.posts; + this.comments = contents.comments; + this.notifications = contents.notifications; + this.shopProducts = contents.shopProducts; + this.shopOrders = contents.shopOrders; + this.shopAccess.clear(); + this.shopIdempotency.clear(); + this.#postIds.reset(contents.postSequence); + this.#commentIds.reset(contents.commentSequence); + this.#notificationIds.reset(contents.notificationSequence); + this.#shopOrderNumbers.reset(contents.shopOrderSequence); + } +} diff --git a/packages/testing/src/server/seed.ts b/packages/testing/src/server/store/seed.builder.ts similarity index 65% rename from packages/testing/src/server/seed.ts rename to packages/testing/src/server/store/seed.builder.ts index 8d4e514..ae0b5fd 100644 --- a/packages/testing/src/server/seed.ts +++ b/packages/testing/src/server/store/seed.builder.ts @@ -1,23 +1,13 @@ -import { type Notification, NotificationType } from 'itd-api'; -import { MockServerSeedError } from '../errors.js'; -import { notificationFixture, userFixture } from '../fixtures.js'; -import type { MockServerSeed } from './contracts.js'; -import type { - BuiltMockServerSeed, - CommentState, - NotificationState, - PostState, - UserState, -} from './entities.js'; - -function actor(user: UserState): Notification['actors'][number] { - return { - id: user.profile.id, - username: user.profile.username, - displayName: user.profile.displayName, - avatar: user.profile.avatar, - }; -} +import { NotificationType } from 'itd-api'; +import { userFixture } from '../../fixtures/models.fixtures.js'; +import { MockServerSeedError } from '../../testing.errors.js'; +import type { CommentRecord } from '../comments/comments.types.js'; +import type { NotificationRecord } from '../notifications/notifications.types.js'; +import type { PostRecord } from '../posts/posts.types.js'; +import type { MockPostSeed, MockServerSeed } from '../server.types.js'; +import type { UserRecord } from '../users/users.types.js'; +import { isImageAvatar, isUsernameFormatValid, usernameKey } from '../users/users.utils.js'; +import type { MockStoreContents } from './store.types.js'; function at(values: readonly T[], index: number): T { const value = values[index]; @@ -35,11 +25,26 @@ function requireUnique(ids: readonly string[], kind: string): void { } } -/** Валидирует seed и собирает независимое состояние, не изменяя работающий сервер. @internal */ -export function buildMockServerSeed( +/** Цепочка репостов не может замыкаться на себя. */ +function requireAcyclicReposts( + postSeeds: readonly MockPostSeed[], + postIds: readonly string[], +): void { + const parents = new Map(postIds.map((id, index) => [id, at(postSeeds, index).originalPostId])); + for (const start of postIds) { + const seen = new Set(); + for (let id: string | null | undefined = start; id; id = parents.get(id)) { + if (seen.has(id)) throw new MockServerSeedError(`Репосты образуют цикл через пост ${id}`); + seen.add(id); + } + } +} + +/** Валидирует seed и собирает независимые данные хранилища, не изменяя работающий сервер. @internal */ +export function buildStoreContents( seed: MockServerSeed | undefined, now: () => string, -): BuiltMockServerSeed { +): MockStoreContents { const userSeeds = seed?.users ?? [{}]; const postSeeds = seed?.posts ?? []; const commentSeeds = seed?.comments ?? []; @@ -53,7 +58,7 @@ export function buildMockServerSeed( const userIds = userSeeds.map( (item, index) => item.id ?? `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, ); - const usernames = userSeeds.map((item, index) => item.username ?? `test-user-${index + 1}`); + const usernames = userSeeds.map((item, index) => item.username ?? `test_user_${index + 1}`); const postIds = postSeeds.map((item, index) => item.id ?? `post-${index + 1}`); const commentIds = commentSeeds.map((item, index) => item.id ?? `comment-${index + 1}`); const notificationIds = notificationSeeds.map( @@ -61,7 +66,14 @@ export function buildMockServerSeed( ); requireUnique(userIds, 'пользователь'); - requireUnique(usernames, 'имя пользователя'); + for (const username of usernames) { + if (!isUsernameFormatValid(username)) { + throw new MockServerSeedError( + `Имя пользователя ${username} должно состоять из латинских букв, цифр и _, от 3 до 32 символов`, + ); + } + } + requireUnique(usernames.map(usernameKey), 'имя пользователя'); requireUnique(postIds, 'пост'); requireUnique(commentIds, 'комментарий'); requireUnique(notificationIds, 'уведомление'); @@ -74,9 +86,12 @@ export function buildMockServerSeed( const commentPostIds = new Map( commentIds.map((id, index) => [id, at(commentSeeds, index).postId]), ); + const commentParentIds = new Map( + commentIds.map((id, index) => [id, at(commentSeeds, index).parentCommentId ?? null]), + ); const userReferences = new Map(); userIds.forEach((id, index) => { - for (const reference of [id, at(usernames, index)]) { + for (const reference of [usernameKey(id), usernameKey(at(usernames, index))]) { const owner = userReferences.get(reference); if (owner !== undefined && owner !== id) { throw new MockServerSeedError( @@ -98,12 +113,10 @@ export function buildMockServerSeed( }; userSeeds.forEach((item, index) => { - for (const followed of item.following ?? []) { - if (!knownUsers.has(followed)) { - throw new MockServerSeedError( - `Пользователь ${userIds[index]} подписан на отсутствующего пользователя ${followed}`, - ); - } + const id = at(userIds, index); + requireKnownUsers(item.following ?? [], `Пользователь ${id} в подписках`); + if (item.following?.includes(id)) { + throw new MockServerSeedError(`Пользователь ${id} подписан сам на себя`); } }); postSeeds.forEach((item, index) => { @@ -116,7 +129,13 @@ export function buildMockServerSeed( ); } requireKnownUsers(item.likedBy ?? [], `Пост ${postIds[index]}`); + if (item.originalPostId && !knownPosts.has(item.originalPostId)) { + throw new MockServerSeedError( + `Пост ${postIds[index]} репостит отсутствующий пост ${item.originalPostId}`, + ); + } }); + requireAcyclicReposts(postSeeds, postIds); commentSeeds.forEach((item, index) => { if (!knownPosts.has(item.postId)) { throw new MockServerSeedError(`У комментария ${commentIds[index]} нет поста ${item.postId}`); @@ -131,6 +150,11 @@ export function buildMockServerSeed( `У комментария ${commentIds[index]} нет родительского комментария ${item.parentCommentId}`, ); } + if (item.parentCommentId && commentParentIds.get(item.parentCommentId)) { + throw new MockServerSeedError( + `Комментарий ${commentIds[index]} отвечает на ответ: ветки комментариев двухуровневые`, + ); + } if (item.parentCommentId && commentPostIds.get(item.parentCommentId) !== item.postId) { throw new MockServerSeedError( `Родительский комментарий ${item.parentCommentId} относится к другому посту`, @@ -149,17 +173,13 @@ export function buildMockServerSeed( `Уведомление принадлежит отсутствующему пользователю ${item.userId}`, ); } - for (const actorId of item.actorIds ?? []) { - if (!knownUsers.has(actorId)) { - throw new MockServerSeedError(`В уведомлении указан отсутствующий участник ${actorId}`); - } - } + requireKnownUsers(item.actorIds ?? [], `Уведомление пользователя ${item.userId}`); } - const users = new Map(); - const posts = new Map(); - const comments = new Map(); - const notifications: NotificationState[] = []; + const users = new Map(); + const posts = new Map(); + const comments = new Map(); + const notifications: NotificationRecord[] = []; userSeeds.forEach((item, index) => { const { following = [], deactivated = false, ...fields } = item; const id = at(userIds, index); @@ -170,6 +190,9 @@ export function buildMockServerSeed( createdAt: item.createdAt ?? now(), ...fields, }); + if (profile.clanAvatar === undefined && !isImageAvatar(profile.avatar)) { + profile.clanAvatar = profile.avatar; + } users.set(id, { profile, following: new Set(following), deactivated }); }); postSeeds.forEach((item, index) => { @@ -178,7 +201,10 @@ export function buildMockServerSeed( id, authorId: item.authorId, content: item.content ?? '', - wallRecipientId: item.wallRecipientId ?? null, + spans: structuredClone([...(item.spans ?? [])]), + originalPostId: item.originalPostId ?? null, + wallRecipientId: + item.wallRecipientId === item.authorId ? null : (item.wallRecipientId ?? null), createdAt: item.createdAt ?? now(), editedAt: null, likedBy: new Set(item.likedBy), @@ -201,15 +227,11 @@ export function buildMockServerSeed( }); notificationSeeds.forEach((item, index) => { const createdAt = item.createdAt ?? now(); - const actors = (item.actorIds ?? []) - .map((id) => users.get(id)) - .filter((user): user is UserState => user !== undefined) - .map(actor); - const value = notificationFixture({ + notifications.push({ id: at(notificationIds, index), + userId: item.userId, type: item.type ?? NotificationType.PostReaction, - rawType: item.type ?? NotificationType.PostReaction, - actors, + actorIds: [...(item.actorIds ?? [])], entityId: item.entityId ?? null, parentEntityId: item.parentEntityId ?? null, preview: item.preview ?? null, @@ -217,7 +239,6 @@ export function buildMockServerSeed( createdAt, updatedAt: createdAt, }); - notifications.push({ userId: item.userId, value }); }); return { diff --git a/packages/testing/src/server/store/snapshot.builder.ts b/packages/testing/src/server/store/snapshot.builder.ts new file mode 100644 index 0000000..5b803b2 --- /dev/null +++ b/packages/testing/src/server/store/snapshot.builder.ts @@ -0,0 +1,25 @@ +import type { MockServerSnapshot } from '../server.types.js'; +import type { UserPresenter } from '../users/users.presenter.js'; +import type { MockStore } from './mock.store.js'; + +/** Независимая копия данных хранилища. Связи представлены идентификаторами. @internal */ +export function createSnapshot(store: MockStore, users: UserPresenter): MockServerSnapshot { + return structuredClone({ + users: [...store.users.values()].map((user) => ({ + ...users.myProfile(user), + following: [...user.following].sort(), + deactivated: user.deactivated, + })), + posts: [...store.posts.values()].map((post) => ({ + ...post, + likedBy: [...post.likedBy].sort(), + })), + comments: [...store.comments.values()].map((comment) => ({ + ...comment, + likedBy: [...comment.likedBy].sort(), + })), + notifications: store.notifications.map((notification) => ({ ...notification })), + shopProducts: [...store.shopProducts.values()], + shopOrders: [...store.shopOrders.values()], + }); +} diff --git a/packages/testing/src/server/store/store.types.ts b/packages/testing/src/server/store/store.types.ts new file mode 100644 index 0000000..ba93c12 --- /dev/null +++ b/packages/testing/src/server/store/store.types.ts @@ -0,0 +1,21 @@ +import type { ShopProduct } from 'itd-api'; +import type { CommentRecord } from '../comments/comments.types.js'; +import type { NotificationRecord } from '../notifications/notifications.types.js'; +import type { PostRecord } from '../posts/posts.types.js'; +import type { ShopOrderRecord } from '../shop/shop.types.js'; +import type { UserRecord } from '../users/users.types.js'; + +/** Полный набор данных хранилища, которым оно атомарно заменяется. @internal */ +export interface MockStoreContents { + users: Map; + posts: Map; + comments: Map; + /** Сначала новые. */ + notifications: NotificationRecord[]; + shopProducts: Map; + shopOrders: Map; + postSequence: number; + commentSequence: number; + notificationSequence: number; + shopOrderSequence: number; +} diff --git a/packages/testing/src/server/users/users.constants.ts b/packages/testing/src/server/users/users.constants.ts new file mode 100644 index 0000000..34df562 --- /dev/null +++ b/packages/testing/src/server/users/users.constants.ts @@ -0,0 +1,20 @@ +import { UsernameUnavailableReason } from 'itd-api'; + +/** Допустимое имя пользователя: латинские буквы, цифры и `_`, от 3 до 32 символов. @internal */ +export const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,32}$/; + +/** + * Почему имя пользователя нельзя занять. Причину неверного формата API присылает в `reason`, + * у занятого имени причины в ответе нет. @internal + */ +export const UsernameIssue = Object.freeze({ + InvalidFormat: UsernameUnavailableReason.InvalidFormat, + Taken: 'TAKEN', +} as const); +export type UsernameIssue = (typeof UsernameIssue)[keyof typeof UsernameIssue]; + +/** Роль пользователя в ответе `/api/profile`. @internal */ +export const UserRole = Object.freeze({ + User: 'user', +} as const); +export type UserRole = (typeof UserRole)[keyof typeof UserRole]; diff --git a/packages/testing/src/server/users/users.presenter.ts b/packages/testing/src/server/users/users.presenter.ts new file mode 100644 index 0000000..a4c776f --- /dev/null +++ b/packages/testing/src/server/users/users.presenter.ts @@ -0,0 +1,134 @@ +import type { + Author, + AuthState, + AuthUser, + CommentReplyTo, + FollowResult, + MyProfile, + PublicProfile, + UsernameAvailability, + UserSummary, +} from 'itd-api'; +import { publicProfileFixture } from '../../fixtures/models.fixtures.js'; +import type { PostService } from '../posts/posts.service.js'; +import type { NumberedSlice } from '../shared/pagination.types.js'; +import { numberedPagination } from '../shared/pagination.utils.js'; +import { UsernameIssue, UserRole } from './users.constants.js'; +import type { UserService } from './users.service.js'; +import type { UserPage, UserRecord, UserReference } from './users.types.js'; +import { clanOf } from './users.utils.js'; + +/** Профили в форме ответов API. Счётчики вычисляются при каждом вызове. @internal */ +export class UserPresenter { + readonly #users: UserService; + readonly #posts: PostService; + + constructor(users: UserService, posts: PostService) { + this.#users = users; + this.#posts = posts; + } + + reference(user: UserRecord): UserReference { + return { + id: user.profile.id, + username: user.profile.username, + displayName: user.profile.displayName, + avatar: user.profile.avatar, + }; + } + + /** Автор поста или комментария: краткая запись с признаком верификации. */ + author(user: UserRecord): Author { + return { ...this.reference(user), verified: user.profile.verified }; + } + + /** Адресат ответа на комментарий. */ + replyTo(user: UserRecord): CommentReplyTo { + return { + id: user.profile.id, + username: user.profile.username, + displayName: user.profile.displayName, + }; + } + + /** Состояние авторизации для `/api/profile`; без пользователя — неавторизованный ответ. */ + authState(user: UserRecord | undefined): AuthState { + return { + authenticated: user !== undefined, + banned: false, + user: user ? this.sessionUser(user) : null, + }; + } + + followResult(target: UserRecord): FollowResult { + return { following: true, followersCount: this.#users.followersCount(target) }; + } + + userPage(slice: NumberedSlice, viewer: UserRecord): UserPage { + return { + users: slice.items.map((user) => this.userSummary(viewer, user)), + pagination: numberedPagination(slice), + }; + } + + sessionUser(user: UserRecord): AuthUser { + return { + id: user.profile.id, + username: user.profile.username, + displayName: user.profile.displayName, + avatar: user.profile.avatar, + clanAvatar: clanOf(user), + bio: user.profile.bio, + verified: user.profile.verified, + isPhoneVerified: user.profile.isPhoneVerified, + roles: [UserRole.User], + }; + } + + myProfile(user: UserRecord): MyProfile { + return { + ...user.profile, + ...this.#counters(user), + subscription: { ...user.profile.subscription }, + }; + } + + publicProfile(viewer: UserRecord, user: UserRecord): PublicProfile { + return publicProfileFixture({ + ...user.profile, + ...this.#counters(user), + isFollowing: this.#users.isFollowing(viewer, user), + isFollowedBy: this.#users.isFollowing(user, viewer), + }); + } + + /** Запись списка подписчиков или подписок относительно текущего пользователя. */ + userSummary(viewer: UserRecord, user: UserRecord): UserSummary { + return { ...this.author(user), isFollowing: this.#users.isFollowing(viewer, user) }; + } + + /** Пользователь в результатах поиска. */ + searchUser(user: UserRecord): UserSummary { + return { + ...this.author(user), + hasNuksta: user.profile.subscription.isActive, + followersCount: this.#users.followersCount(user), + }; + } + + /** Свободно ли имя. Причину ответ называет только для неверного формата. */ + usernameAvailability(issue: UsernameIssue | undefined): UsernameAvailability { + if (issue === undefined) return { available: true }; + return issue === UsernameIssue.InvalidFormat + ? { available: false, reason: UsernameIssue.InvalidFormat } + : { available: false }; + } + + #counters(user: UserRecord): Pick { + return { + followersCount: this.#users.followersCount(user), + followingCount: this.#users.followingCount(user), + postsCount: this.#posts.activeCountBy(user), + }; + } +} diff --git a/packages/testing/src/server/users/users.routes.ts b/packages/testing/src/server/users/users.routes.ts new file mode 100644 index 0000000..6fa6d5b --- /dev/null +++ b/packages/testing/src/server/users/users.routes.ts @@ -0,0 +1,142 @@ +import { HttpMethod } from '../../http/http.constants.js'; +import type { MockRequest } from '../../http/request.types.js'; +import { apiResponse, emptyResponse, jsonResponse } from '../../http/responses.utils.js'; +import { MockDomainError } from '../shared/domain.errors.js'; +import { numberedPage } from '../shared/pagination.utils.js'; +import { numberedQuery, objectBody, stringField } from '../shared/request.utils.js'; +import type { MockRouteContext } from '../shared/route.types.js'; +import type { ProfilePatch, UserRecord } from './users.types.js'; + +/** Как и прод, списки подписчиков и подписок отдают не больше 20 записей за раз. */ +const USER_LIST_LIMIT = 20; +const TOP_CLANS_LIMIT = 10; + +/** Баннер по `bannerId`: идентификатор файла сохраняется как есть, `null` удаляет баннер. */ +function bannerOf(value: unknown): string | null { + if (value === null || typeof value === 'string') return value; + throw MockDomainError.validation('bannerId должен быть строкой или null'); +} + +function profilePatch(body: Record): ProfilePatch { + const patch: ProfilePatch = {}; + for (const key of ['username', 'displayName', 'avatar', 'bio'] as const) { + const value = stringField(body, key); + if (value !== undefined) patch[key] = value; + } + if ('bannerId' in body) patch.banner = bannerOf(body.bannerId); + return patch; +} + +export function registerUserRoutes({ + services, + presenters, + auth, + route, + requireAuth, +}: MockRouteContext): void { + const { users } = services; + + route(HttpMethod.Get, '/api/profile', (request) => { + const user = auth.authenticate(request); + return apiResponse(presenters.users.authState(user)); + }); + + route( + HttpMethod.Get, + '/api/users/me', + requireAuth((_request, user) => apiResponse(presenters.users.myProfile(user))), + ); + + route( + HttpMethod.Put, + '/api/users/me', + requireAuth((request, user) => { + users.updateProfile(user, profilePatch(objectBody(request))); + return apiResponse(presenters.users.myProfile(user)); + }), + ); + + route( + HttpMethod.Delete, + '/api/users/me', + requireAuth((_request, user) => { + users.deactivate(user); + return emptyResponse(); + }), + ); + + route( + HttpMethod.Post, + '/api/users/me/restore', + requireAuth( + (_request, user) => { + users.restore(user); + return emptyResponse(); + }, + { allowDeactivated: true }, + ), + ); + + route(HttpMethod.Get, '/api/users/check-username', (request) => + jsonResponse( + presenters.users.usernameAvailability( + users.validateUsername(request.query.get('username') ?? ''), + ), + ), + ); + + route( + HttpMethod.Get, + '/api/users/:user', + requireAuth((request, viewer) => + apiResponse(presenters.users.publicProfile(viewer, users.require(request.params.user ?? ''))), + ), + ); + + const userPage = (request: MockRequest, viewer: UserRecord, list: readonly UserRecord[]) => + apiResponse( + presenters.users.userPage( + numberedPage(list, numberedQuery(request, USER_LIST_LIMIT)), + viewer, + ), + ); + + route( + HttpMethod.Get, + '/api/users/:user/followers', + requireAuth((request, viewer) => + userPage(request, viewer, users.followers(users.require(request.params.user ?? ''))), + ), + ); + + route( + HttpMethod.Get, + '/api/users/:user/following', + requireAuth((request, viewer) => + userPage(request, viewer, users.following(users.require(request.params.user ?? ''))), + ), + ); + + route(HttpMethod.Get, '/api/users/stats/top-clans', () => + jsonResponse({ clans: users.topClans(TOP_CLANS_LIMIT) }), + ); + + route( + HttpMethod.Post, + '/api/users/:user/follow', + requireAuth((request, viewer) => { + const target = users.require(request.params.user ?? ''); + users.follow(viewer, target); + return apiResponse(presenters.users.followResult(target)); + }), + ); + + route( + HttpMethod.Delete, + '/api/users/:user/follow', + requireAuth((request, viewer) => { + users.unfollow(viewer, users.require(request.params.user ?? '')); + return emptyResponse(); + }), + ); +} diff --git a/packages/testing/src/server/users/users.service.ts b/packages/testing/src/server/users/users.service.ts new file mode 100644 index 0000000..6c3d3d0 --- /dev/null +++ b/packages/testing/src/server/users/users.service.ts @@ -0,0 +1,191 @@ +import { type Clan, ItdErrorCode, NotificationType } from 'itd-api'; +import type { NotificationService } from '../notifications/notifications.service.js'; +import type { AccessRelation } from '../shared/access.types.js'; +import { MockErrorCode } from '../shared/domain.constants.js'; +import { MockDomainError } from '../shared/domain.errors.js'; +import type { MockStore } from '../store/mock.store.js'; +import { UsernameIssue } from './users.constants.js'; +import type { ProfilePatch, UserRecord } from './users.types.js'; +import { clanOf, isImageAvatar, isUsernameFormatValid, usernameKey } from './users.utils.js'; + +/** Пользователи, их профили и граф подписок. @internal */ +export class UserService { + readonly #store: MockStore; + readonly #notifications: NotificationService; + + constructor(store: MockStore, notifications: NotificationService) { + this.#store = store; + this.#notifications = notifications; + } + + get(userId: string): UserRecord | undefined { + return this.#store.users.get(userId); + } + + /** Ищет по `id`, затем по текущему username. */ + find(reference: string): UserRecord | undefined { + return ( + this.#store.users.get(reference) ?? + [...this.#store.users.values()].find((user) => user.profile.username === reference) + ); + } + + /** Активный пользователь по `id` или username. Деактивированный для других не существует. */ + require(reference: string): UserRecord { + const user = this.find(reference); + if (!user || user.deactivated) throw MockDomainError.notFound('User not found'); + return user; + } + + /** Существует ли пользователь и не деактивирован ли он. */ + isActive(userId: string): boolean { + const user = this.#store.users.get(userId); + return user !== undefined && !user.deactivated; + } + + /** + * Проверяет, можно ли занять имя. Имя `owner` и его `id` не считаются занятыми — так + * пользователь может оставить своё имя или сменить в нём регистр. + */ + validateUsername(username: string, owner?: UserRecord): UsernameIssue | undefined { + if (!isUsernameFormatValid(username)) return UsernameIssue.InvalidFormat; + const key = usernameKey(username); + for (const user of this.#store.users.values()) { + if (user === owner) continue; + if (usernameKey(user.profile.username) === key || usernameKey(user.profile.id) === key) { + return UsernameIssue.Taken; + } + } + return undefined; + } + + /** Применяет изменения целиком или не применяет ни одного, если новое имя занять нельзя. */ + updateProfile(user: UserRecord, patch: ProfilePatch): void { + if (patch.username !== undefined) this.#assertUsername(user, patch.username); + Object.assign(user.profile, patch); + if (patch.avatar !== undefined && !isImageAvatar(patch.avatar)) { + user.profile.clanAvatar = patch.avatar; + } + } + + deactivate(user: UserRecord): void { + user.deactivated = true; + } + + restore(user: UserRecord): void { + user.deactivated = false; + } + + /** Подписывает на пользователя. Уведомление получает только новая подписка. */ + follow(follower: UserRecord, target: UserRecord): void { + if (follower === target) { + throw MockDomainError.badRequest( + MockErrorCode.CannotFollowSelf, + 'Нельзя подписаться на себя', + ); + } + if (follower.following.has(target.profile.id)) return; + follower.following.add(target.profile.id); + this.#notifications.notify({ + recipientId: target.profile.id, + type: NotificationType.Follow, + actorId: follower.profile.id, + entityId: follower.profile.id, + }); + } + + unfollow(follower: UserRecord, target: UserRecord): void { + follower.following.delete(target.profile.id); + } + + isFollowing(follower: UserRecord, target: UserRecord): boolean { + return follower.following.has(target.profile.id); + } + + /** Подписчики пользователя в порядке хранения. */ + followers(user: UserRecord): UserRecord[] { + return [...this.#store.users.values()].filter((candidate) => + candidate.following.has(user.profile.id), + ); + } + + /** Подписки пользователя в порядке подписки. */ + following(user: UserRecord): UserRecord[] { + return [...user.following] + .map((id) => this.#store.users.get(id)) + .filter((candidate): candidate is UserRecord => candidate !== undefined); + } + + /** Кланы — группы активных пользователей с одним `clanAvatar`, самые большие первыми. */ + topClans(limit: number): Clan[] { + const members = new Map(); + for (const user of this.#store.users.values()) { + if (user.deactivated) continue; + const clan = clanOf(user); + members.set(clan, (members.get(clan) ?? 0) + 1); + } + return [...members] + .map(([avatar, memberCount]) => ({ avatar, memberCount })) + .sort((a, b) => b.memberCount - a.memberCount || a.avatar.localeCompare(b.avatar)) + .slice(0, limit); + } + + /** + * Активные пользователи, у которых запрос входит в username или отображаемое имя без учёта + * регистра. Сначала точное совпадение username, затем совпадение начала username. + */ + search(query: string, limit: number): UserRecord[] { + const needle = query.toLowerCase(); + if (!needle) return []; + const rank = (user: UserRecord): number => { + const username = usernameKey(user.profile.username); + if (username === needle) return 0; + return username.startsWith(needle) ? 1 : 2; + }; + return [...this.#store.users.values()] + .filter( + (user) => + !user.deactivated && + (usernameKey(user.profile.username).includes(needle) || + user.profile.displayName.toLowerCase().includes(needle)), + ) + .sort( + (a, b) => + rank(a) - rank(b) || + usernameKey(a.profile.username).localeCompare(usernameKey(b.profile.username)), + ) + .slice(0, limit); + } + + /** Отношение владельца ресурса к обращающемуся для проверки политик доступа. */ + relation(owner: UserRecord, viewer: UserRecord): AccessRelation { + return { + isOwner: owner === viewer, + follows: this.isFollowing(viewer, owner), + followedBy: this.isFollowing(owner, viewer), + }; + } + + followersCount(user: UserRecord): number { + let count = 0; + for (const candidate of this.#store.users.values()) { + if (candidate.following.has(user.profile.id)) count += 1; + } + return count; + } + + followingCount(user: UserRecord): number { + return user.following.size; + } + + #assertUsername(user: UserRecord, username: string): void { + switch (this.validateUsername(username, user)) { + case UsernameIssue.InvalidFormat: + throw MockDomainError.validation( + 'Имя пользователя: латинские буквы, цифры и _, от 3 до 32 символов', + ); + case UsernameIssue.Taken: + throw new MockDomainError(409, ItdErrorCode.USERNAME_TAKEN, 'Username is already taken'); + } + } +} diff --git a/packages/testing/src/server/users/users.types.ts b/packages/testing/src/server/users/users.types.ts new file mode 100644 index 0000000..7e0bfa2 --- /dev/null +++ b/packages/testing/src/server/users/users.types.ts @@ -0,0 +1,27 @@ +import type { MyProfile, Notification, UserSummary } from 'itd-api'; +import type { NumberedPagination } from '../shared/pagination.types.js'; + +/** Пользователь в хранилище mock-server. Производные счётчики профиля здесь не хранятся. @internal */ +export interface UserRecord { + profile: MyProfile; + following: Set; + deactivated: boolean; +} + +/** Изменяемые поля своего профиля. `banner: null` удаляет баннер. @internal */ +export interface ProfilePatch { + username?: string; + displayName?: string; + avatar?: string; + bio?: string; + banner?: string | null; +} + +/** Краткие данные пользователя в составе поста, комментария или уведомления. @internal */ +export type UserReference = Notification['actors'][number]; + +/** Страница подписчиков или подписок в форме ответа API. @internal */ +export interface UserPage { + users: UserSummary[]; + pagination: NumberedPagination; +} diff --git a/packages/testing/src/server/users/users.utils.ts b/packages/testing/src/server/users/users.utils.ts new file mode 100644 index 0000000..9812f94 --- /dev/null +++ b/packages/testing/src/server/users/users.utils.ts @@ -0,0 +1,22 @@ +import { USERNAME_PATTERN } from './users.constants.js'; +import type { UserRecord } from './users.types.js'; + +/** @internal */ +export function isUsernameFormatValid(username: string): boolean { + return USERNAME_PATTERN.test(username); +} + +/** Ключ сравнения имён: имена уникальны без учёта регистра. @internal */ +export function usernameKey(username: string): string { + return username.toLowerCase(); +} + +/** Аватар — адрес изображения, а не эмодзи клана. @internal */ +export function isImageAvatar(avatar: string): boolean { + return /^https?:\/\//i.test(avatar); +} + +/** Клан пользователя: `clanAvatar`, а без него — эмодзи-аватар. @internal */ +export function clanOf(user: Pick): string { + return user.profile.clanAvatar ?? user.profile.avatar; +} diff --git a/packages/testing/src/errors.ts b/packages/testing/src/testing.errors.ts similarity index 100% rename from packages/testing/src/errors.ts rename to packages/testing/src/testing.errors.ts diff --git a/packages/testing/test/clock.test.ts b/packages/testing/test/clock/test-clock.factory.test.ts similarity index 97% rename from packages/testing/test/clock.test.ts rename to packages/testing/test/clock/test-clock.factory.test.ts index e3c5ef7..d26c78c 100644 --- a/packages/testing/test/clock.test.ts +++ b/packages/testing/test/clock/test-clock.factory.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createTestClock } from '../src/index.js'; +import { createTestClock } from '../../src/index.js'; describe('createTestClock', () => { it('выполняет задачи по времени и порядку постановки', async () => { diff --git a/packages/testing/test/events/events.utils.test.ts b/packages/testing/test/events/events.utils.test.ts new file mode 100644 index 0000000..4cd4da4 --- /dev/null +++ b/packages/testing/test/events/events.utils.test.ts @@ -0,0 +1,54 @@ +import { + ItdClient, + type NotificationEventContext, + type NotificationEvents, + NotificationUpdateType, +} from 'itd-api'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createMockFetch, sseResponse, waitForUpdate } from '../../src/index.js'; + +describe('события уведомлений', () => { + it('сохраняет флейвор контекста в waitForUpdate()', () => { + const check = (stream: NotificationEvents) => { + expectTypeOf(waitForUpdate(stream)).toEqualTypeOf>(); + }; + + expectTypeOf(check).returns.toEqualTypeOf(); + }); + + it('проверяет настоящий разбор SSE без сети', async () => { + const mock = createMockFetch(); + mock.get( + '/api/notifications/stream', + sseResponse([ + { event: 'notification', data: '{' }, + { event: 'unread_count', data: { payload: { count: 4 } } }, + ]), + ); + const client = new ItdClient({ + baseUrl: 'https://mock.itd.test', + fetch: mock.fetch, + auth: 'test-token', + retry: false, + rateLimit: false, + userAgent: false, + events: { + notifications: { transport: 'sse', syncCount: false, maxAttempts: 0 }, + }, + }); + const stream = client.notifications.events; + const parseError = new Promise<{ raw: string }>((resolve) => + stream.once('parseError', resolve), + ); + const update = waitForUpdate(stream); + + await stream.connect(); + await expect(parseError).resolves.toMatchObject({ raw: '{' }); + await expect(update).resolves.toMatchObject({ + update: { type: NotificationUpdateType.UnreadCount, data: 4 }, + }); + await stream.drain(); + stream.disconnect(); + mock.assertDone(); + }); +}); diff --git a/packages/testing/test/mock-fetch.test.ts b/packages/testing/test/fetch/mock-fetch.factory.test.ts similarity index 99% rename from packages/testing/test/mock-fetch.test.ts rename to packages/testing/test/fetch/mock-fetch.factory.test.ts index 469e00b..0c2dabe 100644 --- a/packages/testing/test/mock-fetch.test.ts +++ b/packages/testing/test/fetch/mock-fetch.factory.test.ts @@ -8,7 +8,7 @@ import { HttpMethod, hangingResponse, userFixture, -} from '../src/index.js'; +} from '../../src/index.js'; async function settleUntil(condition: () => boolean): Promise { for (let index = 0; index < 100 && !condition(); index += 1) await Promise.resolve(); diff --git a/packages/testing/test/operations.test.ts b/packages/testing/test/operations/mock-operations.factory.test.ts similarity index 99% rename from packages/testing/test/operations.test.ts rename to packages/testing/test/operations/mock-operations.factory.test.ts index 66c9a64..b759698 100644 --- a/packages/testing/test/operations.test.ts +++ b/packages/testing/test/operations/mock-operations.factory.test.ts @@ -5,7 +5,7 @@ import { UnhandledOperationError, UnusedMockOperationsError, userFixture, -} from '../src/index.js'; +} from '../../src/index.js'; describe('createMockOperations', () => { it('подменяет логическую операцию без вызова транспорта', async () => { diff --git a/packages/testing/test/server.test.ts b/packages/testing/test/server.test.ts deleted file mode 100644 index 7a5a070..0000000 --- a/packages/testing/test/server.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { - type ClientPlugin, - createShopFeature, - ItdClient, - type NotificationEventContext, - type NotificationEvents, - NotificationUpdateType, -} from 'itd-api'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { - apiErrorResponse, - createMockFetch, - createMockServer, - createTestClock, - HttpMethod, - sseResponse, - waitForUpdate, -} from '../src/index.js'; - -const ALICE = '00000000-0000-4000-8000-000000000001'; -const BOB = '00000000-0000-4000-8000-000000000002'; -const CAROL = '00000000-0000-4000-8000-000000000003'; - -async function settleUntil(condition: () => boolean): Promise { - for (let index = 0; index < 100 && !condition(); index += 1) await Promise.resolve(); - if (!condition()) throw new Error('Условие не наступило'); -} - -function makeServer() { - return createMockServer({ - clock: createTestClock('2026-08-01T10:00:00Z'), - seed: { - users: [ - { id: ALICE, username: 'alice', displayName: 'Алиса' }, - { id: BOB, username: 'bob', displayName: 'Боб' }, - ], - }, - }); -} - -describe('createMockServer', () => { - it('выполняет сценарий магазина с доступом по коду из письма', async () => { - const server = createMockServer({ - clock: createTestClock('2026-08-01T10:00:00Z'), - seed: { - shopProducts: [ - { - id: 'hoodie', - title: 'Худи', - category: 'apparel', - price: 4_000, - images: [], - sizes: ['M'], - colors: [], - description: '', - specs: [], - status: 'available', - stockLeft: 10, - }, - ], - }, - }); - const client = new ItdClient(server.clientOptions({ as: 'test-user-1' })); - const created = await client.shop.orders.create({ - items: [{ productId: 'hoodie', size: 'M', color: null, qty: 1 }], - recipient: { - name: 'Покупатель', - phone: '+70000000000', - email: 'buyer@example.test', - country: 'Россия', - city: 'Москва', - address: 'Тестовая улица, 1', - cityCode: 44, - deliveryPoint: '', - comment: '', - }, - consents: [], - consentContext: { form: 'checkout', page: '/shop', visitorId: 'visitor' }, - }); - expect(await client.shop.orders.get(created.number)).toMatchObject({ total: 4_500 }); - - const anonymous = new ItdClient({ - baseUrl: 'https://mock.itd.test', - fetch: server.fetch, - retry: false, - rateLimit: false, - userAgent: false, - }); - const access = anonymous.install(createShopFeature()); - await access.requestCode('buyer@example.test'); - await access.verifyCode('123456'); - await expect(access.list()).resolves.toMatchObject({ - items: [{ number: created.number }], - }); - server.assertNoUnsupportedRequests(); - }); - - it('отклоняет неизвестный и истёкший токены заказов', async () => { - const clock = createTestClock('2026-08-01T10:00:00Z'); - const server = createMockServer({ clock }); - const client = new ItdClient({ - baseUrl: 'https://mock.itd.test', - fetch: server.fetch, - retry: false, - rateLimit: false, - userAgent: false, - }); - - await expect( - client.shop.orders.list({ orderAccessToken: 'unknown', useItdAuth: false }), - ).rejects.toMatchObject({ status: 401 }); - - const email = 'buyer@example.test'; - await client.shop.orders.requestAccessCode(email); - const { token } = await client.shop.orders.verifyAccessCode(email, '123456'); - await clock.advanceBy(600_001); - - await expect( - client.shop.orders.list({ orderAccessToken: token, useItdAuth: false }), - ).rejects.toMatchObject({ status: 401 }); - server.assertNoUnsupportedRequests(); - }); - - it('сохраняет флейвор контекста в waitForUpdate()', () => { - const check = (stream: NotificationEvents) => { - expectTypeOf(waitForUpdate(stream)).toEqualTypeOf>(); - }; - - expectTypeOf(check).returns.toEqualTypeOf(); - }); - - it('сохраняет mock-домен по умолчанию и нормализует пользовательский baseUrl', async () => { - const defaultServer = createMockServer(); - const defaultClient = new ItdClient(defaultServer.clientOptions({ as: 'test-user-1' })); - await defaultClient.users.me(); - expect(defaultServer.requests[0]?.url).toBe('https://mock.itd.test/api/users/me'); - - const customServer = createMockServer({ baseUrl: 'https://custom.test/' }); - const customClient = new ItdClient(customServer.clientOptions({ as: 'test-user-1' })); - await customClient.users.me(); - expect(customServer.requests[0]?.url).toBe('https://custom.test/api/users/me'); - }); - - it('выполняет пользовательский сценарий в общем состоянии', async () => { - const server = makeServer(); - const alice = new ItdClient(server.clientOptions({ as: 'alice' })); - const bob = new ItdClient(server.clientOptions({ as: 'bob' })); - - await expect(alice.auth.check()).resolves.toMatchObject({ - authenticated: true, - banned: false, - user: { id: ALICE, username: 'alice', roles: ['user'] }, - }); - await alice.users.follow('bob'); - const post = await bob.posts.create({ content: 'Проверяем сервер' }); - await expect(bob.posts.update(post.id, { content: 'Проверяем обновление' })).resolves.toEqual({ - id: post.id, - content: 'Проверяем обновление', - spans: [], - updatedAt: expect.any(String), - }); - await alice.posts.like(post.id); - const comment = await alice.posts.comment(post.id, 'Работает'); - await expect(alice.comments.update(comment.id, 'Точно работает')).resolves.toEqual({ - id: comment.id, - content: 'Точно работает', - editedAt: expect.any(String), - }); - - expect((await bob.posts.get(post.id)).likesCount).toBe(1); - expect((await bob.posts.comments(post.id)).items).toMatchObject([{ id: comment.id }]); - expect((await bob.notifications.list()).items.map((item) => item.type)).toEqual([ - 'post_comment', - 'post_reaction', - 'follow', - ]); - expect(await bob.notifications.count()).toBe(3); - expect(server.snapshot().users.find((user) => user.id === ALICE)?.following).toEqual([BOB]); - - await bob.posts.remove(post.id); - await expect(alice.posts.get(post.id)).rejects.toMatchObject({ status: 404 }); - await expect(bob.posts.restore(post.id)).resolves.toBeUndefined(); - await expect(alice.posts.get(post.id)).resolves.toMatchObject({ id: post.id }); - server.assertNoUnsupportedRequests(); - }); - - it('доставляет действия сервера в связанный транспорт событий', async () => { - const server = makeServer(); - const alice = new ItdClient(server.clientOptions({ as: 'alice' })); - const transport = server.notificationEvents({ as: 'bob' }); - const bob = new ItdClient({ - ...server.clientOptions({ as: 'bob' }), - events: { notifications: { transport, syncCount: false, jitter: 0 } }, - }); - const stream = bob.notifications.events; - - await stream.connect(); - await transport.waitForConnection(0); - const update = waitForUpdate(stream); - await alice.users.follow('bob'); - - await expect(update).resolves.toMatchObject({ - update: { type: NotificationUpdateType.Notification }, - }); - await stream.drain(); - stream.disconnect(); - }); - - it('проверяет настоящий разбор SSE без сети', async () => { - const mock = createMockFetch(); - mock.get( - '/api/notifications/stream', - sseResponse([ - { event: 'notification', data: '{' }, - { event: 'unread_count', data: { payload: { count: 4 } } }, - ]), - ); - const client = new ItdClient({ - baseUrl: 'https://mock.itd.test', - fetch: mock.fetch, - auth: 'test-token', - retry: false, - rateLimit: false, - userAgent: false, - events: { - notifications: { transport: 'sse', syncCount: false, maxAttempts: 0 }, - }, - }); - const stream = client.notifications.events; - const parseError = new Promise<{ raw: string }>((resolve) => - stream.once('parseError', resolve), - ); - const update = waitForUpdate(stream); - - await stream.connect(); - await expect(parseError).resolves.toMatchObject({ raw: '{' }); - await expect(update).resolves.toMatchObject({ - update: { type: NotificationUpdateType.UnreadCount, data: 4 }, - }); - await stream.drain(); - stream.disconnect(); - mock.assertDone(); - }); - - it('управляет переподключением через тестовые часы', async () => { - const clock = createTestClock('2026-08-01T10:00:00Z'); - const server = createMockServer({ - clock, - seed: { users: [{ id: ALICE, username: 'alice' }] }, - }); - const transport = server.notificationEvents({ as: 'alice' }); - const client = new ItdClient({ - ...server.clientOptions({ as: 'alice' }), - events: { - notifications: { transport, syncCount: false, backoff: [100], jitter: 0 }, - }, - }); - const stream = client.notifications.events; - - await stream.connect(); - await transport.waitForConnection(0); - const reconnected = transport.waitForConnection(1); - transport.close(); - await settleUntil(() => clock.pending === 1); - await clock.advanceBy(100); - await reconnected; - - expect(transport.connections).toBe(2); - stream.disconnect(); - }); - - it('управляет повтором по HTTP-дате Retry-After через тестовые часы', async () => { - const clock = createTestClock(0); - const server = createMockServer({ - clock, - seed: { users: [{ id: ALICE, username: 'alice' }] }, - }); - server.failNext( - HttpMethod.Get, - '/api/users/me', - apiErrorResponse(429, 'RATE_LIMIT_EXCEEDED', 'Повторите запрос позже', { - headers: { 'Retry-After': new Date(5_000).toUTCString() }, - }), - ); - const client = new ItdClient({ - ...server.clientOptions({ as: 'alice' }), - timeout: 0, - retry: { attempts: 2, jitter: 0, maxDelay: 10_000 }, - }); - - const profile = client.users.me(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await settleUntil(() => clock.pending === 1); - await clock.advanceBy(4_999); - expect(server.requests).toHaveLength(1); - await clock.advanceBy(1); - - await expect(profile).resolves.toMatchObject({ id: ALICE }); - expect(server.requests).toHaveLength(2); - }); - - it('видит запрос после преобразования плагином и допускает расширение маршрутов', async () => { - const server = makeServer(); - const plugin: ClientPlugin = { - name: 'test-header', - install({ operations }) { - operations.use((request, next) => - next({ ...request, headers: { ...request.headers, 'X-Plugin': 'active' } }), - ); - }, - }; - const client = new ItdClient(server.clientOptions({ as: 'alice' })); - client.use(plugin); - await client.users.me(); - expect(server.requests[0]?.headers['x-plugin']).toBe('active'); - - const remove = server.override(HttpMethod.Get, '/plugin/status', (request) => - apiResponse({ plugin: request.headers.get('x-plugin') }), - ); - await expect( - client.request({ method: HttpMethod.Get, path: '/plugin/status' }), - ).resolves.toEqual({ plugin: 'active' }); - remove(); - }); - - it('сохраняет плоский ответ списка уведомлений для raw-запросов и interceptor', async () => { - const server = makeServer(); - const client = new ItdClient(server.clientOptions({ as: 'alice' })); - let hookBody: unknown; - client.use({ - name: 'response-reader', - install({ attempts }) { - attempts.use(async ({ url }, next) => { - const response = await next(); - if (new URL(url).pathname === '/api/notifications/') { - hookBody = await response.clone().json(); - } - return response; - }); - }, - }); - - const raw = await client.request({ - method: HttpMethod.Get, - path: '/api/notifications/', - raw: true, - }); - - expect(raw).toEqual({ notifications: [], hasMore: false }); - expect(hookBody).toEqual(raw); - }); - - it('отправляет уведомление адресату ответа', async () => { - const server = createMockServer({ - seed: { - users: [ - { id: ALICE, username: 'alice' }, - { id: BOB, username: 'bob' }, - { id: CAROL, username: 'carol' }, - ], - posts: [{ id: 'post-1', authorId: ALICE }], - comments: [{ id: 'comment-1', postId: 'post-1', authorId: ALICE }], - }, - }); - const alice = new ItdClient(server.clientOptions({ as: 'alice' })); - const bob = new ItdClient(server.clientOptions({ as: 'bob' })); - const carol = new ItdClient(server.clientOptions({ as: 'carol' })); - - await carol.comments.reply('comment-1', (comment) => - comment.content('Ответ Бобу').replyTo(BOB), - ); - - expect(await bob.notifications.count()).toBe(1); - expect(await alice.notifications.count()).toBe(0); - - await carol.comments.reply('comment-1', 'Ответ Алисе'); - - expect(await alice.notifications.count()).toBe(1); - }); - - it('не маскирует отсутствующие маршруты', async () => { - const server = makeServer(); - const client = new ItdClient(server.clientOptions({ as: 'alice' })); - await expect( - client.request({ method: HttpMethod.Get, path: '/api/not-implemented' }), - ).rejects.toMatchObject({ status: 501 }); - expect(() => server.assertNoUnsupportedRequests()).toThrow(/не реализует/); - }); - - it('проверяет связи в исходных данных до запуска теста', () => { - expect(() => - createMockServer({ - seed: { posts: [{ authorId: 'missing', content: 'Некорректная запись' }] }, - }), - ).toThrow(/нет автора missing/); - }); - - it.each([ - { - name: 'реакция отсутствующего пользователя', - seed: { - users: [{ id: ALICE, username: 'alice' }], - posts: [{ id: 'post-1', authorId: ALICE, likedBy: ['missing'] }], - }, - }, - { - name: 'повторяющееся имя пользователя', - seed: { - users: [ - { id: ALICE, username: 'same' }, - { id: BOB, username: 'same' }, - ], - }, - }, - { - name: 'повторяющийся идентификатор уведомления', - seed: { - users: [{ id: ALICE, username: 'alice' }], - notifications: [ - { id: 'notification-1', userId: ALICE }, - { id: 'notification-1', userId: ALICE }, - ], - }, - }, - { - name: 'родительский комментарий другого поста', - seed: { - users: [{ id: ALICE, username: 'alice' }], - posts: [ - { id: 'post-1', authorId: ALICE }, - { id: 'post-2', authorId: ALICE }, - ], - comments: [ - { id: 'comment-1', postId: 'post-1', authorId: ALICE }, - { - id: 'comment-2', - postId: 'post-2', - authorId: ALICE, - parentCommentId: 'comment-1', - }, - ], - }, - }, - ])('отклоняет повреждённый seed: $name', ({ seed }) => { - expect(() => createMockServer({ seed })).toThrow(); - }); - - it('не изменяет состояние и исходный seed при ошибке reset()', () => { - const server = makeServer(); - const before = server.snapshot(); - - expect(() => - server.reset({ - users: [ - { id: ALICE, username: 'same' }, - { id: BOB, username: 'same' }, - ], - }), - ).toThrow(); - expect(server.snapshot()).toEqual(before); - - server.reset(); - expect(server.snapshot()).toEqual(before); - }); -}); - -function apiResponse(data: unknown): Response { - return new Response(JSON.stringify({ data }), { - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/packages/testing/test/server/comments/comments.routes.test.ts b/packages/testing/test/server/comments/comments.routes.test.ts new file mode 100644 index 0000000..e6a9a55 --- /dev/null +++ b/packages/testing/test/server/comments/comments.routes.test.ts @@ -0,0 +1,78 @@ +import { ItdClient } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { createMockServer } from '../../../src/index.js'; +import { ALICE, BOB, CAROL } from '../test-server.utils.js'; + +describe('createMockServer: комментарии', () => { + it('отправляет уведомление адресату ответа', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + { id: CAROL, username: 'carol' }, + ], + posts: [{ id: 'post-1', authorId: ALICE }], + comments: [{ id: 'comment-1', postId: 'post-1', authorId: ALICE }], + }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + const carol = new ItdClient(server.clientOptions({ as: 'carol' })); + + await carol.comments.reply('comment-1', (comment) => + comment.content('Ответ Бобу').replyTo(BOB), + ); + + expect(await bob.notifications.count()).toBe(1); + expect(await alice.notifications.count()).toBe(0); + + await carol.comments.reply('comment-1', 'Ответ Алисе'); + + expect(await alice.notifications.count()).toBe(1); + }); + + it('отдаёт настоящий признак верификации автора комментария', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', verified: true }, + { id: BOB, username: 'bob' }, + ], + posts: [{ id: 'post-1', authorId: BOB }], + comments: [{ id: 'comment-1', postId: 'post-1', authorId: ALICE }], + }, + }); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + const [comment] = (await bob.posts.comments('post-1')).items; + expect(comment?.author).toMatchObject({ id: ALICE, verified: true }); + }); + + it('ставит ответ на ответ в ветку комментария первого уровня', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + { id: CAROL, username: 'carol' }, + ], + posts: [{ id: 'post-1', authorId: ALICE }], + comments: [{ id: 'root', postId: 'post-1', authorId: ALICE }], + }, + }); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + const carol = new ItdClient(server.clientOptions({ as: 'carol' })); + + const first = await bob.comments.reply('root', 'первый'); + const second = await carol.comments.reply(first.id, 'ответ на ответ'); + + expect(second.replyTo).toMatchObject({ id: BOB }); + expect((await bob.comments.replies('root')).items.map((c) => c.id)).toEqual([ + first.id, + second.id, + ]); + expect((await bob.posts.comments('post-1')).items[0]?.repliesCount).toBe(2); + expect(await bob.notifications.count()).toBe(1); + }); +}); diff --git a/packages/testing/test/server/comments/comments.service.test.ts b/packages/testing/test/server/comments/comments.service.test.ts new file mode 100644 index 0000000..8c43b01 --- /dev/null +++ b/packages/testing/test/server/comments/comments.service.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { makeRuntime, userOf } from '../test-runtime.utils.js'; +import { ALICE, BOB } from '../test-server.utils.js'; + +describe('CommentService', () => { + it('создаёт уведомление только при появлении реакции', () => { + const runtime = makeRuntime({ + posts: [{ id: 'post-1', authorId: ALICE }], + comments: [{ id: 'comment-1', postId: 'post-1', authorId: ALICE }], + }); + const { comments, notifications } = runtime.services; + const bob = userOf(runtime, BOB); + const comment = comments.requireActive('comment-1'); + + comments.like(bob, comment); + comments.like(bob, comment); + expect(comment.likedBy.size).toBe(1); + expect(notifications.forUser(ALICE)).toHaveLength(1); + }); + + it('не отвечает отсутствующему адресату', () => { + const runtime = makeRuntime({ + posts: [{ id: 'post-1', authorId: ALICE }], + comments: [{ id: 'comment-1', postId: 'post-1', authorId: ALICE }], + }); + const { comments } = runtime.services; + + expect(() => + comments.reply(comments.requireActive('comment-1'), userOf(runtime, BOB), 'Ответ', 'missing'), + ).toThrow(expect.objectContaining({ status: 404, code: 'NOT_FOUND' })); + }); + + it('не учитывает удалённые комментарии и ответы в счётчиках', () => { + const runtime = makeRuntime({ + posts: [{ id: 'post-1', authorId: ALICE }], + comments: [ + { id: 'comment-1', postId: 'post-1', authorId: ALICE }, + { id: 'reply-1', postId: 'post-1', authorId: BOB, parentCommentId: 'comment-1' }, + { + id: 'reply-2', + postId: 'post-1', + authorId: BOB, + parentCommentId: 'comment-1', + deleted: true, + }, + ], + }); + const { comments, posts } = runtime.services; + + expect(comments.activeCountFor(posts.requireActive('post-1'))).toBe(2); + expect(comments.activeReplyCount(comments.requireActive('comment-1'))).toBe(1); + expect(comments.topLevel(posts.requireActive('post-1')).map((item) => item.id)).toEqual([ + 'comment-1', + ]); + }); +}); diff --git a/packages/testing/test/server/hashtags/hashtags.routes.test.ts b/packages/testing/test/server/hashtags/hashtags.routes.test.ts new file mode 100644 index 0000000..97a2129 --- /dev/null +++ b/packages/testing/test/server/hashtags/hashtags.routes.test.ts @@ -0,0 +1,42 @@ +import { ItdClient } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { HttpMethod } from '../../../src/index.js'; +import { makeServer } from '../test-server.utils.js'; + +describe('createMockServer: хэштеги', () => { + it('собирает хэштеги из разметки опубликованных постов', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + const first = await alice.posts.create((post) => + post.markup((m) => m.text('привет ').hashtag('итд')), + ); + await bob.posts.create((post) => + post.markup((m) => m.hashtag('ИТДграм').text(' и ').hashtag('итд')), + ); + + expect(first.spans).toEqual([{ type: 'hashtag', offset: 7, length: 4, tag: 'итд' }]); + await expect(alice.hashtags.trending()).resolves.toEqual([ + { id: 'hashtag-итд', name: 'итд', postsCount: 2 }, + { id: 'hashtag-итдграм', name: 'итдграм', postsCount: 1 }, + ]); + expect((await alice.hashtags.posts('ИТДГРАМ')).items).toHaveLength(1); + server.assertNoUnsupportedRequests(); + }); + + it('отвечает на неизвестный хэштег пустой страницей', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect( + alice.request({ method: HttpMethod.Get, path: '/api/hashtags/missing/posts', raw: true }), + ).resolves.toEqual({ + data: { + hashtag: null, + posts: [], + pagination: { hasMore: false, nextCursor: null, limit: 20 }, + }, + }); + }); +}); diff --git a/packages/testing/test/server/hashtags/hashtags.service.test.ts b/packages/testing/test/server/hashtags/hashtags.service.test.ts new file mode 100644 index 0000000..cd23dd4 --- /dev/null +++ b/packages/testing/test/server/hashtags/hashtags.service.test.ts @@ -0,0 +1,55 @@ +import { type Span, SpanType } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { makeRuntime } from '../test-runtime.utils.js'; +import { ALICE } from '../test-server.utils.js'; + +const tags = (...names: string[]): Span[] => + names.map((tag) => ({ type: SpanType.Hashtag, offset: 0, length: tag.length + 1, tag })); + +function makeHashtagRuntime() { + return makeRuntime({ + posts: [ + { id: 'p1', authorId: ALICE, spans: tags('итд'), createdAt: '2026-08-01T09:00:00.000Z' }, + { + id: 'p2', + authorId: ALICE, + spans: tags('итд', 'арт'), + createdAt: '2026-08-01T09:10:00.000Z', + }, + { id: 'p3', authorId: ALICE, spans: tags('итдграм'), createdAt: '2026-08-01T09:20:00.000Z' }, + { id: 'p4', authorId: ALICE, spans: tags('итд'), deleted: true }, + ], + }); +} + +describe('HashtagService', () => { + it('считает только активные посты с тегом', () => { + const runtime = makeHashtagRuntime(); + const { hashtags, posts } = runtime.services; + + expect(hashtags.find('#ИТД')).toEqual({ id: 'hashtag-итд', name: 'итд', postsCount: 2 }); + expect(hashtags.posts('ИТД').map((post) => post.id)).toEqual(['p2', 'p1']); + + posts.restore(posts.requireOwn('p4', runtime.services.users.require(ALICE))); + expect(hashtags.find('итд')?.postsCount).toBe(3); + expect(hashtags.find('missing')).toBeUndefined(); + }); + + it('упорядочивает тренды по самому свежему посту с тегом', () => { + const { hashtags } = makeHashtagRuntime().services; + + expect(hashtags.trending(10).map((hashtag) => hashtag.name)).toEqual(['итдграм', 'арт', 'итд']); + expect(hashtags.trending(1)).toHaveLength(1); + }); + + it('ищет по началу имени, самые популярные первыми', () => { + const { hashtags } = makeHashtagRuntime().services; + + expect(hashtags.searchByPrefix('ИТД', 5).map((hashtag) => hashtag.name)).toEqual([ + 'итд', + 'итдграм', + ]); + expect(hashtags.searchByPrefix('тд', 5)).toEqual([]); + expect(hashtags.searchByPrefix('', 5)).toEqual([]); + }); +}); diff --git a/packages/testing/test/server/hashtags/hashtags.utils.test.ts b/packages/testing/test/server/hashtags/hashtags.utils.test.ts new file mode 100644 index 0000000..a657173 --- /dev/null +++ b/packages/testing/test/server/hashtags/hashtags.utils.test.ts @@ -0,0 +1,34 @@ +import { SpanType } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { hashtagKey, hashtagNames } from '../../../src/server/hashtags/hashtags.utils.js'; + +describe('hashtagNames', () => { + it('берёт имя из tag, а без него из размеченного текста', () => { + expect( + hashtagNames({ + content: '#Итд и #арт', + spans: [ + { type: SpanType.Hashtag, offset: 0, length: 4, tag: 'Итд' }, + { type: SpanType.Hashtag, offset: 7, length: 4 }, + ], + }), + ).toEqual(['итд', 'арт']); + }); + + it('учитывает тег один раз и пропускает другую разметку', () => { + expect( + hashtagNames({ + content: '#итд @bob #ИТД', + spans: [ + { type: SpanType.Hashtag, offset: 0, length: 4, tag: 'итд' }, + { type: SpanType.Mention, offset: 5, length: 4, username: 'bob' }, + { type: SpanType.Hashtag, offset: 10, length: 4, tag: 'ИТД' }, + ], + }), + ).toEqual(['итд']); + }); + + it('сравнивает имена без решётки и регистра', () => { + expect(hashtagKey(' #ИтдГрам ')).toBe('итдграм'); + }); +}); diff --git a/packages/testing/test/server/mock-server.factory.test.ts b/packages/testing/test/server/mock-server.factory.test.ts new file mode 100644 index 0000000..ea67b1f --- /dev/null +++ b/packages/testing/test/server/mock-server.factory.test.ts @@ -0,0 +1,238 @@ +import { type ClientPlugin, ItdClient } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { + apiErrorResponse, + apiResponse, + createMockServer, + createTestClock, + HttpMethod, + ItdTestingError, +} from '../../src/index.js'; +import { ALICE, BOB, makeServer, settleUntil } from './test-server.utils.js'; + +describe('createMockServer: жизненный цикл', () => { + it('сохраняет mock-домен по умолчанию и нормализует пользовательский baseUrl', async () => { + const defaultServer = createMockServer(); + const defaultClient = new ItdClient(defaultServer.clientOptions({ as: 'test_user_1' })); + await defaultClient.users.me(); + expect(defaultServer.requests[0]?.url).toBe('https://mock.itd.test/api/users/me'); + + const customServer = createMockServer({ baseUrl: 'https://custom.test/' }); + const customClient = new ItdClient(customServer.clientOptions({ as: 'test_user_1' })); + await customClient.users.me(); + expect(customServer.requests[0]?.url).toBe('https://custom.test/api/users/me'); + }); + + it('управляет повтором по HTTP-дате Retry-After через тестовые часы', async () => { + const clock = createTestClock(0); + const server = createMockServer({ + clock, + seed: { users: [{ id: ALICE, username: 'alice' }] }, + }); + server.failNext( + HttpMethod.Get, + '/api/users/me', + apiErrorResponse(429, 'RATE_LIMIT_EXCEEDED', 'Повторите запрос позже', { + headers: { 'Retry-After': new Date(5_000).toUTCString() }, + }), + ); + const client = new ItdClient({ + ...server.clientOptions({ as: 'alice' }), + timeout: 0, + retry: { attempts: 2, jitter: 0, maxDelay: 10_000 }, + }); + + const profile = client.users.me(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await settleUntil(() => clock.pending === 1); + await clock.advanceBy(4_999); + expect(server.requests).toHaveLength(1); + await clock.advanceBy(1); + + await expect(profile).resolves.toMatchObject({ id: ALICE }); + expect(server.requests).toHaveLength(2); + }); + + it('видит запрос после преобразования плагином и допускает расширение маршрутов', async () => { + const server = makeServer(); + const plugin: ClientPlugin = { + name: 'test-header', + install({ operations }) { + operations.use((request, next) => + next({ ...request, headers: { ...request.headers, 'X-Plugin': 'active' } }), + ); + }, + }; + const client = new ItdClient(server.clientOptions({ as: 'alice' })); + client.use(plugin); + await client.users.me(); + expect(server.requests[0]?.headers['x-plugin']).toBe('active'); + + const remove = server.override(HttpMethod.Get, '/plugin/status', (request) => + apiResponse({ plugin: request.headers.get('x-plugin') }), + ); + await expect( + client.request({ method: HttpMethod.Get, path: '/plugin/status' }), + ).resolves.toEqual({ plugin: 'active' }); + remove(); + }); + + it('не маскирует отсутствующие маршруты', async () => { + const server = makeServer(); + const client = new ItdClient(server.clientOptions({ as: 'alice' })); + await expect( + client.request({ method: HttpMethod.Get, path: '/api/not-implemented' }), + ).rejects.toMatchObject({ status: 501 }); + expect(() => server.assertNoUnsupportedRequests()).toThrow(/не реализует/); + }); + + it('проверяет связи в исходных данных до запуска теста', () => { + expect(() => + createMockServer({ + seed: { posts: [{ authorId: 'missing', content: 'Некорректная запись' }] }, + }), + ).toThrow(/нет автора missing/); + }); + + it.each([ + { + name: 'реакция отсутствующего пользователя', + seed: { + users: [{ id: ALICE, username: 'alice' }], + posts: [{ id: 'post-1', authorId: ALICE, likedBy: ['missing'] }], + }, + }, + { + name: 'повторяющееся имя пользователя', + seed: { + users: [ + { id: ALICE, username: 'same' }, + { id: BOB, username: 'same' }, + ], + }, + }, + { + name: 'имя пользователя, отличающееся только регистром', + seed: { + users: [ + { id: ALICE, username: 'same' }, + { id: BOB, username: 'SAME' }, + ], + }, + }, + { + name: 'имя пользователя неверного формата', + seed: { users: [{ id: ALICE, username: 'with-dash' }] }, + }, + { + name: 'имя, совпадающее с id другого пользователя', + seed: { + users: [ + { id: 'dave_id', username: 'dave' }, + { id: BOB, username: 'DAVE_ID' }, + ], + }, + }, + { + name: 'подписка на себя', + seed: { users: [{ id: ALICE, username: 'alice', following: [ALICE] }] }, + }, + { + name: 'ответ на ответ', + seed: { + users: [{ id: ALICE, username: 'alice' }], + posts: [{ id: 'post-1', authorId: ALICE }], + comments: [ + { id: 'root', postId: 'post-1', authorId: ALICE }, + { id: 'reply', postId: 'post-1', authorId: ALICE, parentCommentId: 'root' }, + { id: 'nested', postId: 'post-1', authorId: ALICE, parentCommentId: 'reply' }, + ], + }, + }, + { + name: 'репост отсутствующего поста', + seed: { + users: [{ id: ALICE, username: 'alice' }], + posts: [{ id: 'repost', authorId: ALICE, originalPostId: 'missing' }], + }, + }, + { + name: 'цикл репостов', + seed: { + users: [{ id: ALICE, username: 'alice' }], + posts: [ + { id: 'a', authorId: ALICE, originalPostId: 'b' }, + { id: 'b', authorId: ALICE, originalPostId: 'a' }, + ], + }, + }, + { + name: 'повторяющийся идентификатор уведомления', + seed: { + users: [{ id: ALICE, username: 'alice' }], + notifications: [ + { id: 'notification-1', userId: ALICE }, + { id: 'notification-1', userId: ALICE }, + ], + }, + }, + { + name: 'родительский комментарий другого поста', + seed: { + users: [{ id: ALICE, username: 'alice' }], + posts: [ + { id: 'post-1', authorId: ALICE }, + { id: 'post-2', authorId: ALICE }, + ], + comments: [ + { id: 'comment-1', postId: 'post-1', authorId: ALICE }, + { + id: 'comment-2', + postId: 'post-2', + authorId: ALICE, + parentCommentId: 'comment-1', + }, + ], + }, + }, + ])('отклоняет повреждённый seed: $name', ({ seed }) => { + expect(() => createMockServer({ seed })).toThrow(); + }); + + it('не изменяет состояние и исходный seed при ошибке reset()', () => { + const server = makeServer(); + const before = server.snapshot(); + + expect(() => + server.reset({ + users: [ + { id: ALICE, username: 'same' }, + { id: BOB, username: 'same' }, + ], + }), + ).toThrow(); + expect(server.snapshot()).toEqual(before); + + server.reset(); + expect(server.snapshot()).toEqual(before); + }); + + it('хранит пост seed на своей стене без адресата', () => { + const server = createMockServer({ + seed: { + users: [{ id: ALICE, username: 'alice' }], + posts: [{ id: 'own', authorId: ALICE, wallRecipientId: ALICE }], + }, + }); + + expect(server.snapshot().posts[0]?.wallRecipientId).toBeNull(); + }); + + it('сообщает о неподдерживаемом запросе ошибкой пакета', async () => { + const server = makeServer(); + const client = new ItdClient(server.clientOptions({ as: 'alice' })); + await client.request({ method: HttpMethod.Get, path: '/api/not-implemented' }).catch(() => {}); + + expect(() => server.assertNoUnsupportedRequests()).toThrow(ItdTestingError); + }); +}); diff --git a/packages/testing/test/server/notifications/notifications.routes.test.ts b/packages/testing/test/server/notifications/notifications.routes.test.ts new file mode 100644 index 0000000..edba088 --- /dev/null +++ b/packages/testing/test/server/notifications/notifications.routes.test.ts @@ -0,0 +1,87 @@ +import { ItdClient, NotificationUpdateType } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { + createMockServer, + createTestClock, + HttpMethod, + waitForUpdate, +} from '../../../src/index.js'; +import { ALICE, makeServer, settleUntil } from '../test-server.utils.js'; + +describe('createMockServer: уведомления', () => { + it('доставляет действия сервера в связанный транспорт событий', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const transport = server.notificationEvents({ as: 'bob' }); + const bob = new ItdClient({ + ...server.clientOptions({ as: 'bob' }), + events: { notifications: { transport, syncCount: false, jitter: 0 } }, + }); + const stream = bob.notifications.events; + + await stream.connect(); + await transport.waitForConnection(0); + const update = waitForUpdate(stream); + await alice.users.follow('bob'); + + await expect(update).resolves.toMatchObject({ + update: { type: NotificationUpdateType.Notification }, + }); + await stream.drain(); + stream.disconnect(); + }); + + it('управляет переподключением через тестовые часы', async () => { + const clock = createTestClock('2026-08-01T10:00:00Z'); + const server = createMockServer({ + clock, + seed: { users: [{ id: ALICE, username: 'alice' }] }, + }); + const transport = server.notificationEvents({ as: 'alice' }); + const client = new ItdClient({ + ...server.clientOptions({ as: 'alice' }), + events: { + notifications: { transport, syncCount: false, backoff: [100], jitter: 0 }, + }, + }); + const stream = client.notifications.events; + + await stream.connect(); + await transport.waitForConnection(0); + const reconnected = transport.waitForConnection(1); + transport.close(); + await settleUntil(() => clock.pending === 1); + await clock.advanceBy(100); + await reconnected; + + expect(transport.connections).toBe(2); + stream.disconnect(); + }); + + it('сохраняет плоский ответ списка уведомлений для raw-запросов и interceptor', async () => { + const server = makeServer(); + const client = new ItdClient(server.clientOptions({ as: 'alice' })); + let hookBody: unknown; + client.use({ + name: 'response-reader', + install({ attempts }) { + attempts.use(async ({ url }, next) => { + const response = await next(); + if (new URL(url).pathname === '/api/notifications/') { + hookBody = await response.clone().json(); + } + return response; + }); + }, + }); + + const raw = await client.request({ + method: HttpMethod.Get, + path: '/api/notifications/', + raw: true, + }); + + expect(raw).toEqual({ notifications: [], hasMore: false }); + expect(hookBody).toEqual(raw); + }); +}); diff --git a/packages/testing/test/server/notifications/notifications.service.test.ts b/packages/testing/test/server/notifications/notifications.service.test.ts new file mode 100644 index 0000000..c75a721 --- /dev/null +++ b/packages/testing/test/server/notifications/notifications.service.test.ts @@ -0,0 +1,52 @@ +import { NotificationType } from 'itd-api'; +import { describe, expect, it, vi } from 'vitest'; +import { makeRuntime } from '../test-runtime.utils.js'; +import { ALICE, BOB } from '../test-server.utils.js'; + +const follow = (recipientId: string, actorId: string) => ({ + recipientId, + type: NotificationType.Follow, + actorId, + entityId: actorId, +}); + +describe('NotificationService', () => { + it('не уведомляет о действии над собой и неизвестного получателя', () => { + const { notifications } = makeRuntime().services; + + expect(notifications.notify(follow(ALICE, ALICE))).toBeUndefined(); + expect(notifications.notify(follow('missing', ALICE))).toBeUndefined(); + expect(notifications.forUser(ALICE)).toEqual([]); + }); + + it('хранит участников идентификаторами и оповещает подписчиков', () => { + const { notifications } = makeRuntime().services; + const listener = vi.fn(); + notifications.onCreated(listener); + + const record = notifications.notify(follow(BOB, ALICE)); + + expect(record).toMatchObject({ userId: BOB, actorIds: [ALICE], isRead: false }); + expect(listener).toHaveBeenCalledWith(record); + }); + + it('считает только впервые прочитанные уведомления', () => { + const { notifications } = makeRuntime().services; + const first = notifications.notify(follow(BOB, ALICE)); + notifications.notify(follow(BOB, ALICE)); + + expect(notifications.markRead(BOB, first?.id ?? '')).toBe(1); + expect(notifications.markRead(BOB, first?.id ?? '')).toBe(0); + expect(notifications.markAll(BOB)).toBe(1); + expect(notifications.unreadCount(BOB)).toBe(0); + }); + + it('не отмечает чужое уведомление', () => { + const { notifications } = makeRuntime().services; + const record = notifications.notify(follow(BOB, ALICE)); + + expect(() => notifications.markRead(ALICE, record?.id ?? '')).toThrow( + expect.objectContaining({ status: 404, code: 'NOT_FOUND' }), + ); + }); +}); diff --git a/packages/testing/test/server/posts/posts.routes.test.ts b/packages/testing/test/server/posts/posts.routes.test.ts new file mode 100644 index 0000000..a290a24 --- /dev/null +++ b/packages/testing/test/server/posts/posts.routes.test.ts @@ -0,0 +1,440 @@ +import { AccessType, FeedTab, ItdClient } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { createMockServer, HttpMethod } from '../../../src/index.js'; +import { ALICE, BOB, CAROL } from '../test-server.utils.js'; + +function makeContentServer() { + return createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + ], + posts: [{ id: 'post-1', authorId: ALICE }], + comments: [{ id: 'comment-1', postId: 'post-1', authorId: ALICE }], + }, + }); +} + +describe('createMockServer: посты', () => { + it('не дублирует уведомление при повторных реакциях на пост и комментарий', async () => { + const server = makeContentServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await bob.posts.like('post-1'); + await expect(bob.posts.like('post-1')).resolves.toMatchObject({ likesCount: 1 }); + await bob.comments.like('comment-1'); + await bob.comments.like('comment-1'); + + expect((await alice.notifications.list()).items.map((item) => item.type)).toEqual([ + 'comment_reaction', + 'post_reaction', + ]); + }); + + it('отвечает на отсутствующий пост кодом NOT_FOUND', async () => { + const server = makeContentServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect(bob.posts.get('missing')).rejects.toMatchObject({ + status: 404, + code: 'NOT_FOUND', + }); + }); + + it('запрещает менять чужой пост', async () => { + const server = makeContentServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect(bob.posts.remove('post-1')).rejects.toMatchObject({ status: 403 }); + }); + + it('собирает вкладки ленты из подписок и клана', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', avatar: '🦎' }, + { id: BOB, username: 'bob', avatar: '🍅' }, + { id: CAROL, username: 'carol', avatar: '🦎' }, + ], + posts: [ + { id: 'bob-post', authorId: BOB, createdAt: '2026-08-01T09:00:00.000Z' }, + { id: 'carol-post', authorId: CAROL, createdAt: '2026-08-01T09:30:00.000Z' }, + ], + }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const ids = async (tab?: FeedTab) => + (await alice.posts.list(tab ? { tab } : {})).items.map((post) => post.id); + + expect(await ids()).toEqual(['carol-post', 'bob-post']); + expect(await ids(FeedTab.Popular)).toEqual(['carol-post', 'bob-post']); + expect(await ids(FeedTab.Following)).toEqual([]); + expect(await ids(FeedTab.Clan)).toEqual(['carol-post']); + + await alice.users.follow('bob'); + expect(await ids(FeedTab.Following)).toEqual(['bob-post']); + await alice.users.unfollow('bob'); + expect(await ids(FeedTab.Following)).toEqual([]); + server.assertNoUnsupportedRequests(); + }); + + it('отклоняет неизвестную вкладку ленты', async () => { + const server = makeContentServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect( + bob.request({ method: HttpMethod.Get, path: '/api/posts', query: { tab: 'unknown' } }), + ).rejects.toMatchObject({ + status: 400, + code: 'VALIDATION_ERROR', + }); + }); + + it.each([ + [AccessType.Everyone, { stranger: true, follower: true, mutual: true }], + [AccessType.Followers, { stranger: false, follower: true, mutual: true }], + [AccessType.Mutual, { stranger: false, follower: false, mutual: true }], + [AccessType.Nobody, { stranger: false, follower: false, mutual: false }], + ])('показывает лайкнутые посты по политике %s', async (likesVisibility, visible) => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', likesVisibility, following: [CAROL] }, + { id: BOB, username: 'bob', following: [ALICE] }, + { id: CAROL, username: 'carol', following: [ALICE] }, + { id: 'dave_id', username: 'dave' }, + ], + posts: [{ id: 'post-1', authorId: 'dave_id', likedBy: [ALICE] }], + }, + }); + const seen = async (as: string) => + (await new ItdClient(server.clientOptions({ as })).posts.likedByUser('alice')).items.length > + 0; + + expect(await seen('alice')).toBe(true); + expect(await seen('dave')).toBe(visible.stranger); + expect(await seen('bob')).toBe(visible.follower); + expect(await seen('carol')).toBe(visible.mutual); + server.assertNoUnsupportedRequests(); + }); + + it('закрывает список лайков пустой страницей, а не ошибкой', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', likesVisibility: AccessType.Nobody }, + { id: BOB, username: 'bob' }, + ], + posts: [{ id: 'post-1', authorId: BOB, likedBy: [ALICE] }], + }, + }); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect( + bob.request({ method: HttpMethod.Get, path: '/api/posts/user/alice/liked', raw: true }), + ).resolves.toEqual({ + data: { posts: [], pagination: { hasMore: false, nextCursor: null, limit: 20 } }, + }); + }); + + it('листает лайкнутые посты и убирает пост после снятия реакции', async () => { + const posts = Array.from({ length: 5 }, (_, index) => ({ + id: `post-${index + 1}`, + authorId: BOB, + likedBy: [ALICE], + createdAt: `2026-08-01T09:0${index}:00.000Z`, + })); + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + ], + posts, + }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + const seen: string[] = []; + for await (const post of alice.posts.iterateLikedByUser('alice', { limit: 2 })) { + seen.push(post.id); + } + expect(seen).toEqual(['post-5', 'post-4', 'post-3', 'post-2', 'post-1']); + + await alice.posts.unlike('post-5'); + const first = await alice.posts.likedByUser(ALICE, { limit: 1 }); + expect(first.items.map((post) => [post.id, post.isLiked])).toEqual([['post-4', true]]); + }); + + it('отвечает на лайки неизвестного пользователя кодом NOT_FOUND', async () => { + const server = makeContentServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect(bob.posts.likedByUser('missing')).rejects.toMatchObject({ + status: 404, + code: 'NOT_FOUND', + }); + }); + + it('сохраняет разметку при создании и сбрасывает её с новым текстом', async () => { + const server = makeContentServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + const post = await bob.posts.create((p) => p.markup((m) => m.bold('жирно'))); + expect(post.spans).toEqual([{ type: 'bold', offset: 0, length: 5 }]); + expect(server.snapshot().posts.find((item) => item.id === post.id)?.spans).toEqual(post.spans); + + await expect(bob.posts.update(post.id, { content: 'просто' })).resolves.toMatchObject({ + spans: [], + }); + await expect( + bob.request({ + method: HttpMethod.Post, + path: '/api/posts', + body: { content: 'x', spans: [{ type: 'bold', offset: -1, length: 1 }] }, + }), + ).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + }); +}); + +function makeRepostServer() { + return createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', displayName: 'Алиса', verified: true }, + { id: BOB, username: 'bob', displayName: 'Боб', following: [CAROL] }, + { id: CAROL, username: 'carol', displayName: 'Кэрол' }, + ], + posts: [{ id: 'root', authorId: ALICE, content: 'Исходный' }], + }, + }); +} + +describe('createMockServer: репосты', () => { + it('отдаёт репост с урезанным исходным постом и счётчиками для каждого', async () => { + const server = makeRepostServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + const carol = new ItdClient(server.clientOptions({ as: 'carol' })); + + const repost = await carol.posts.repost('root', 'Смотрите'); + + expect(repost).toMatchObject({ + content: 'Смотрите', + author: { id: CAROL }, + isOwner: true, + repostsCount: 0, + originalPost: { + id: 'root', + content: 'Исходный', + spans: [], + author: { id: ALICE, username: 'alice', verified: true }, + attachments: [], + likesCount: 0, + commentsCount: 0, + repostsCount: 1, + viewsCount: 0, + isDeleted: false, + createdAt: expect.any(String), + }, + }); + expect(repost.originalPost).not.toHaveProperty('isReposted'); + expect(repost.originalPost).not.toHaveProperty('originalPost'); + + await expect(carol.posts.get('root')).resolves.toMatchObject({ + repostsCount: 1, + isReposted: true, + originalPost: null, + }); + await expect(alice.posts.get('root')).resolves.toMatchObject({ isReposted: false }); + expect((await alice.notifications.list()).items.map((item) => item.type)).toEqual([ + 'post_repost', + ]); + expect((await bob.posts.list({ tab: FeedTab.Following })).items.map((post) => post.id)).toEqual( + [repost.id], + ); + expect(server.snapshot().posts.find((post) => post.id === repost.id)?.originalPostId).toBe( + 'root', + ); + server.assertNoUnsupportedRequests(); + }); + + it('репостит репост и отменяет репост без изменения исходного поста', async () => { + const server = makeRepostServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + const carol = new ItdClient(server.clientOptions({ as: 'carol' })); + + const first = await carol.posts.repost('root'); + const second = await bob.posts.repost(first.id, 'Цепочка'); + + expect(second.originalPost).toMatchObject({ id: first.id, repostsCount: 1 }); + await expect(bob.posts.get('root')).resolves.toMatchObject({ repostsCount: 1 }); + + await carol.posts.unrepost('root'); + await expect(bob.posts.get('root')).resolves.toMatchObject({ repostsCount: 0 }); + await expect(bob.posts.get(first.id)).rejects.toMatchObject({ status: 404 }); + await expect(bob.posts.get(second.id)).resolves.toMatchObject({ originalPost: null }); + await expect(carol.posts.unrepost('root')).rejects.toMatchObject({ + status: 404, + code: 'NOT_FOUND', + }); + }); + + it('скрывает исходный пост, пока он удалён', async () => { + const server = makeRepostServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + const repost = await bob.posts.repost('root'); + + await alice.posts.remove('root'); + await expect(bob.posts.get(repost.id)).resolves.toMatchObject({ originalPost: null }); + await alice.posts.restore('root'); + await expect(bob.posts.get(repost.id)).resolves.toMatchObject({ + originalPost: { id: 'root' }, + }); + }); + + it('отдаёт дату исходного поста в формате прода, а SDK приводит её к ISO', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + ], + posts: [{ id: 'root', authorId: ALICE, createdAt: '2026-10-04T19:32:04.381Z' }], + }, + }); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + const repost = await bob.posts.repost('root'); + + const raw = (await bob.request({ + method: HttpMethod.Get, + path: `/api/posts/${repost.id}`, + raw: true, + })) as { data: { originalPost: { createdAt: string } } }; + + expect(raw.data.originalPost.createdAt).toBe('2026-10-04 19:32:04.381000+00'); + expect(repost.originalPost?.createdAt).toBe('2026-10-04T19:32:04.381Z'); + }); + + it('отменяет репост и после удаления исходного поста', async () => { + const server = makeRepostServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + await bob.posts.repost('root'); + await alice.posts.remove('root'); + + await expect( + bob.request({ method: HttpMethod.Delete, path: '/api/posts/root/repost', raw: true }), + ).resolves.toEqual({ data: { success: true, repostsCount: 0 } }); + const [notification] = (await alice.notifications.list()).items; + expect(notification).toMatchObject({ type: 'post_repost', parentEntityId: null }); + }); + + it('повторяет поведение прода при повторном репосте', async () => { + const server = makeRepostServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + const first = await bob.posts.repost('root', 'проверка'); + const second = await bob.posts.repost('root', 'повтор'); + + expect(second.id).not.toBe(first.id); + await expect(bob.posts.get('root')).resolves.toMatchObject({ + repostsCount: 1, + isReposted: true, + }); + expect((await alice.notifications.list()).items.map((item) => item.type)).toEqual([ + 'post_repost', + ]); + + await bob.posts.unrepost('root'); + await expect(bob.posts.get(first.id)).rejects.toMatchObject({ status: 404 }); + await expect(bob.posts.get(second.id)).rejects.toMatchObject({ status: 404 }); + await expect(bob.posts.get('root')).resolves.toMatchObject({ + repostsCount: 0, + isReposted: false, + }); + }); +}); + +function makeWallServer() { + return createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', wallAccess: AccessType.Followers }, + { id: BOB, username: 'bob' }, + ], + }, + }); +} + +describe('createMockServer: стена и поля поста', () => { + it('пускает на стену по wallAccess и показывает пост владельцу стены', async () => { + const server = makeWallServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect( + bob.posts.create({ content: 'Привет', wallRecipientId: ALICE }), + ).rejects.toMatchObject({ status: 403, code: 'WRITE_ACCESS_RESTRICTED' }); + + await bob.users.follow('alice'); + const post = await bob.posts.create({ content: 'Привет', wallRecipientId: ALICE }); + + expect(post.wallRecipientId).toBe(ALICE); + expect((await alice.posts.byUser('alice')).items.map((item) => item.id)).toEqual([post.id]); + expect((await alice.notifications.list()).items.map((item) => item.type)).toEqual([ + 'wall_post', + 'follow', + ]); + }); + + it('хранит пост на своей стене без адресата и отвергает неизвестного адресата', async () => { + const server = makeWallServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect( + bob.posts.create({ content: 'Себе', wallRecipientId: BOB }), + ).resolves.toMatchObject({ wallRecipientId: null }); + await expect( + bob.posts.create({ + content: 'Никому', + wallRecipientId: '00000000-0000-4000-8000-000000000099', + }), + ).rejects.toMatchObject({ status: 404, code: 'NOT_FOUND' }); + }); + + it.each([ + ['attachmentIds', { attachmentIds: ['file-1'] }], + ['poll', { poll: { question: 'Да?', options: [{ text: 'Да' }, { text: 'Нет' }] } }], + ])('отклоняет неподдерживаемое поле %s', async (_field, extra) => { + const server = makeWallServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect( + bob.request({ + method: HttpMethod.Post, + path: '/api/posts', + body: { content: 'x', ...extra }, + }), + ).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(server.snapshot().posts).toEqual([]); + }); + + it('принимает адресата стены только по id', async () => { + const server = makeWallServer(); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + await bob.users.follow('alice'); + + await expect( + bob.request({ + method: HttpMethod.Post, + path: '/api/posts', + body: { content: 'x', wallRecipientId: 'alice' }, + }), + ).rejects.toMatchObject({ status: 404, code: 'NOT_FOUND' }); + }); +}); diff --git a/packages/testing/test/server/posts/posts.service.test.ts b/packages/testing/test/server/posts/posts.service.test.ts new file mode 100644 index 0000000..a33b18f --- /dev/null +++ b/packages/testing/test/server/posts/posts.service.test.ts @@ -0,0 +1,272 @@ +import { AccessType, NotificationType } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { makeRuntime, userOf } from '../test-runtime.utils.js'; +import { ALICE, BOB, CAROL } from '../test-server.utils.js'; + +describe('PostService', () => { + it('отдаёт ленту без удалённых постов, сначала новые', () => { + const runtime = makeRuntime({ + posts: [ + { id: 'old', authorId: ALICE, createdAt: '2026-08-01T09:00:00.000Z' }, + { id: 'new', authorId: BOB, createdAt: '2026-08-01T09:30:00.000Z' }, + { id: 'deleted', authorId: BOB, createdAt: '2026-08-01T09:45:00.000Z', deleted: true }, + ], + }); + + expect(runtime.services.posts.feed().map((post) => post.id)).toEqual(['new', 'old']); + }); + + it('показывает на стене свои посты и адресованные владельцу', () => { + const runtime = makeRuntime({ + posts: [ + { id: 'own', authorId: ALICE }, + { id: 'to-alice', authorId: BOB, wallRecipientId: ALICE }, + { id: 'bob', authorId: BOB }, + ], + }); + + const wall = runtime.services.posts.wall(userOf(runtime, ALICE)).map((post) => post.id); + expect(wall.sort()).toEqual(['own', 'to-alice']); + }); + + it('создаёт уведомление только при появлении реакции', () => { + const runtime = makeRuntime({ posts: [{ id: 'post-1', authorId: ALICE }] }); + const { posts, notifications } = runtime.services; + const bob = userOf(runtime, BOB); + const post = posts.requireActive('post-1'); + + posts.like(bob, post); + posts.like(bob, post); + expect(post.likedBy.size).toBe(1); + expect(notifications.forUser(ALICE)).toHaveLength(1); + posts.unlike(bob, post); + posts.unlike(bob, post); + expect(post.likedBy.size).toBe(0); + }); + + it('отличает чужой пост от отсутствующего', () => { + const runtime = makeRuntime({ posts: [{ id: 'post-1', authorId: ALICE }] }); + const bob = userOf(runtime, BOB); + + expect(() => runtime.services.posts.requireOwn('post-1', bob)).toThrow( + expect.objectContaining({ status: 403 }), + ); + expect(() => runtime.services.posts.requireOwn('missing', bob)).toThrow( + expect.objectContaining({ status: 404, code: 'NOT_FOUND' }), + ); + }); + + it('собирает ленту подписок только из постов тех, на кого подписан пользователь', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice', following: [BOB] }, + { id: BOB, username: 'bob' }, + { id: CAROL, username: 'carol' }, + ], + posts: [ + { id: 'own', authorId: ALICE }, + { id: 'bob', authorId: BOB }, + { id: 'carol', authorId: CAROL }, + ], + }); + + const feed = runtime.services.posts.followingFeed(userOf(runtime, ALICE)); + expect(feed.map((post) => post.id)).toEqual(['bob']); + }); + + it('собирает ленту клана по аватару авторов, включая свои посты', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice', avatar: '🦎' }, + { id: BOB, username: 'bob', avatar: '🦎' }, + { id: CAROL, username: 'carol', avatar: '🍅' }, + ], + posts: [ + { id: 'own', authorId: ALICE, createdAt: '2026-08-01T09:00:00.000Z' }, + { id: 'bob', authorId: BOB, createdAt: '2026-08-01T09:30:00.000Z' }, + { id: 'carol', authorId: CAROL }, + ], + }); + const alice = userOf(runtime, ALICE); + const { posts, users } = runtime.services; + + expect(posts.clanFeed(alice).map((post) => post.id)).toEqual(['bob', 'own']); + users.updateProfile(alice, { avatar: '🍅' }); + expect(posts.clanFeed(alice).map((post) => post.id)).toEqual(['carol', 'own']); + }); + + it('отдаёт лайкнутые активные посты, если политика открывает список', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice', likesVisibility: AccessType.Followers }, + { id: BOB, username: 'bob', following: [ALICE] }, + { id: CAROL, username: 'carol' }, + ], + posts: [ + { id: 'old', authorId: BOB, likedBy: [ALICE], createdAt: '2026-08-01T09:00:00.000Z' }, + { id: 'new', authorId: CAROL, likedBy: [ALICE], createdAt: '2026-08-01T09:30:00.000Z' }, + { id: 'deleted', authorId: BOB, likedBy: [ALICE], deleted: true }, + { id: 'other', authorId: BOB, likedBy: [CAROL] }, + ], + }); + const { posts } = runtime.services; + const alice = userOf(runtime, ALICE); + + expect(posts.likedBy(alice, userOf(runtime, BOB)).map((post) => post.id)).toEqual([ + 'new', + 'old', + ]); + expect(posts.likedBy(alice, alice)).toHaveLength(2); + expect(posts.likedBy(alice, userOf(runtime, CAROL))).toEqual([]); + }); +}); + +function makeRepostRuntime() { + return makeRuntime({ posts: [{ id: 'root', authorId: ALICE, content: 'Исходный' }] }); +} + +describe('PostService: репосты', () => { + it('создаёт новый репост на каждый вызов и уведомляет автора поста один раз', () => { + const runtime = makeRepostRuntime(); + const { posts, notifications } = runtime.services; + const bob = userOf(runtime, BOB); + + const repost = posts.repost(bob, 'root', 'Смотрите'); + const again = posts.repost(bob, 'root', 'Другой текст'); + expect(repost).toMatchObject({ authorId: BOB, content: 'Смотрите', originalPostId: 'root' }); + expect(again).toMatchObject({ authorId: BOB, content: 'Другой текст', originalPostId: 'root' }); + expect(again.id).not.toBe(repost.id); + expect(posts.hasReposted(bob, 'root')).toBe(true); + + expect(notifications.forUser(ALICE)).toEqual([ + expect.objectContaining({ + type: NotificationType.PostRepost, + entityId: repost.id, + parentEntityId: null, + preview: 'Исходный', + }), + ]); + expect(posts.repostsCount(posts.requireActive('root'))).toBe(1); + }); + + it('считает репост репоста у непосредственного родителя', () => { + const runtime = makeRepostRuntime(); + const { posts } = runtime.services; + + const first = posts.repost(userOf(runtime, BOB), 'root', ''); + const second = posts.repost(userOf(runtime, CAROL), first.id, ''); + const third = posts.repost(userOf(runtime, ALICE), second.id, ''); + + expect([third.originalPostId, second.originalPostId]).toEqual([second.id, first.id]); + expect(posts.repostsCount(posts.requireActive('root'))).toBe(1); + expect(posts.repostsCount(first)).toBe(1); + expect(posts.repostsCount(second)).toBe(1); + }); + + it('не репостит удалённый пост', () => { + const runtime = makeRuntime({ posts: [{ id: 'root', authorId: ALICE, deleted: true }] }); + + expect(() => runtime.services.posts.repost(userOf(runtime, BOB), 'root', '')).toThrow( + expect.objectContaining({ status: 404, code: 'NOT_FOUND' }), + ); + }); + + it('отменяет все репосты пользователя одним вызовом', () => { + const runtime = makeRepostRuntime(); + const { posts, notifications } = runtime.services; + const bob = userOf(runtime, BOB); + const reposts = [posts.repost(bob, 'root', '1'), posts.repost(bob, 'root', '2')]; + posts.repost(userOf(runtime, CAROL), 'root', ''); + + expect(posts.repostsCount(posts.requireActive('root'))).toBe(2); + expect(posts.unrepost(bob, 'root')).toBe(1); + expect(reposts.map((repost) => repost.deleted)).toEqual([true, true]); + + posts.repost(bob, 'root', '3'); + expect(notifications.forUser(ALICE).filter((n) => n.actorIds[0] === BOB)).toHaveLength(2); + }); + + it('отменяет только репост текущего пользователя', () => { + const runtime = makeRepostRuntime(); + const { posts } = runtime.services; + const bob = userOf(runtime, BOB); + const carol = userOf(runtime, CAROL); + const bobRepost = posts.repost(bob, 'root', ''); + const carolRepost = posts.repost(carol, 'root', ''); + + posts.unrepost(bob, 'root'); + + expect(bobRepost.deleted).toBe(true); + expect(carolRepost.deleted).toBe(false); + expect(posts.requireActive('root').deleted).toBe(false); + expect(() => posts.unrepost(bob, 'root')).toThrow( + expect.objectContaining({ status: 404, code: 'NOT_FOUND' }), + ); + expect(posts.repost(bob, 'root', '')).not.toBe(bobRepost); + }); + + it('теряет родителя, пока исходный пост удалён', () => { + const runtime = makeRepostRuntime(); + const { posts } = runtime.services; + const alice = userOf(runtime, ALICE); + const repost = posts.repost(userOf(runtime, BOB), 'root', ''); + const root = posts.requireOwn('root', alice); + + posts.remove(root); + expect(posts.activeParent(repost)).toBeUndefined(); + expect(repost.deleted).toBe(false); + + posts.restore(root); + expect(posts.activeParent(repost)).toBe(root); + }); +}); + +const newPost = (wallRecipientId: string | null) => ({ + content: 'Привет', + spans: [], + wallRecipientId, +}); + +describe('PostService: стена', () => { + it('хранит пост на своей стене без адресата', () => { + const runtime = makeRuntime(); + const alice = userOf(runtime, ALICE); + + expect(runtime.services.posts.create(alice, newPost(ALICE)).wallRecipientId).toBeNull(); + }); + + it('пишет на чужую стену, если wallAccess разрешает', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice', wallAccess: AccessType.Followers }, + { id: BOB, username: 'bob', following: [ALICE] }, + { id: CAROL, username: 'carol' }, + ], + }); + const { posts, notifications } = runtime.services; + + expect(posts.create(userOf(runtime, BOB), newPost(ALICE)).wallRecipientId).toBe(ALICE); + expect(notifications.forUser(ALICE)).toHaveLength(1); + expect(() => posts.create(userOf(runtime, CAROL), newPost(ALICE))).toThrow( + expect.objectContaining({ status: 403, code: 'WRITE_ACCESS_RESTRICTED' }), + ); + }); + + it('не пишет на стену отсутствующего или деактивированного пользователя', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob', deactivated: true }, + ], + }); + const { posts } = runtime.services; + const alice = userOf(runtime, ALICE); + + for (const recipient of ['missing', BOB]) { + expect(() => posts.create(alice, newPost(recipient))).toThrow( + expect.objectContaining({ status: 404, code: 'NOT_FOUND' }), + ); + } + expect(runtime.store.posts.size).toBe(0); + }); +}); diff --git a/packages/testing/test/server/routes.contract.test.ts b/packages/testing/test/server/routes.contract.test.ts new file mode 100644 index 0000000..346074f --- /dev/null +++ b/packages/testing/test/server/routes.contract.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { createMockServer, HttpMethod } from '../../src/index.js'; +import { ALICE, BOB } from './test-server.utils.js'; + +const ORDER = { + items: [{ productId: 'hoodie', size: 'M', color: null, qty: 1 }], + recipient: { + name: 'Покупатель', + phone: '+70000000000', + email: 'buyer@example.test', + country: 'Россия', + city: 'Москва', + address: 'Тестовая улица, 1', + cityCode: 44, + deliveryPoint: '', + comment: '', + }, + consents: [], + consentContext: { form: 'checkout', page: '/shop', visitorId: 'visitor' }, +}; + +/** Каждый встроенный маршрут mock-server с допустимыми параметрами. */ +const SUPPORTED: ReadonlyArray = [ + [HttpMethod.Get, '/api/profile'], + [HttpMethod.Get, '/api/users/me'], + [HttpMethod.Put, '/api/users/me', { displayName: 'Алиса' }], + [HttpMethod.Get, '/api/users/check-username?username=free_name'], + [HttpMethod.Get, '/api/users/bob'], + [HttpMethod.Get, '/api/users/bob/followers'], + [HttpMethod.Get, '/api/users/bob/following'], + [HttpMethod.Get, '/api/users/stats/top-clans'], + [HttpMethod.Post, '/api/users/bob/follow'], + [HttpMethod.Delete, '/api/users/bob/follow'], + [HttpMethod.Get, '/api/posts'], + [HttpMethod.Get, '/api/posts?tab=following'], + [HttpMethod.Get, '/api/posts/user/bob'], + [HttpMethod.Get, '/api/posts/user/bob/liked'], + [HttpMethod.Post, '/api/posts', { content: 'Пост' }], + [HttpMethod.Get, '/api/posts/post-1'], + [HttpMethod.Put, '/api/posts/own-post', { content: 'Изменён' }], + [HttpMethod.Post, '/api/posts/post-1/like'], + [HttpMethod.Delete, '/api/posts/post-1/like'], + [HttpMethod.Post, '/api/posts/post-1/repost', { content: '' }], + [HttpMethod.Delete, '/api/posts/post-1/repost'], + [HttpMethod.Delete, '/api/posts/own-post'], + [HttpMethod.Post, '/api/posts/own-post/restore'], + [HttpMethod.Get, '/api/posts/post-1/comments'], + [HttpMethod.Post, '/api/posts/post-1/comments', { content: 'Комментарий' }], + [HttpMethod.Get, '/api/comments/comment-1/replies'], + [HttpMethod.Post, '/api/comments/comment-1/replies', { content: 'Ответ' }], + [HttpMethod.Patch, '/api/comments/own-comment', { content: 'Изменён' }], + [HttpMethod.Post, '/api/comments/comment-1/like'], + [HttpMethod.Delete, '/api/comments/comment-1/like'], + [HttpMethod.Delete, '/api/comments/own-comment'], + [HttpMethod.Post, '/api/comments/own-comment/restore'], + [HttpMethod.Get, '/api/hashtags/trending'], + [HttpMethod.Get, '/api/hashtags/итд/posts'], + [HttpMethod.Get, '/api/search?q=bob'], + [HttpMethod.Get, '/api/notifications/'], + [HttpMethod.Get, '/api/notifications/count'], + [HttpMethod.Post, '/api/notifications/notification-1/read'], + [HttpMethod.Post, '/api/notifications/read-batch', { ids: ['notification-1'] }], + [HttpMethod.Post, '/api/notifications/read-all'], + [HttpMethod.Get, '/api/v1/shop/products'], + [HttpMethod.Get, '/api/v1/shop/products/hoodie'], + [HttpMethod.Get, '/api/v1/shop/delivery/countries'], + [HttpMethod.Get, '/api/v1/shop/delivery/cities?q=Москва'], + [HttpMethod.Get, '/api/v1/shop/delivery/points?cityCode=44'], + [HttpMethod.Post, '/api/v1/shop/delivery/calculate', {}], + [HttpMethod.Post, '/api/v1/shop/orders', ORDER], + [HttpMethod.Get, '/api/v1/shop/orders/my'], + [HttpMethod.Post, '/api/v1/shop/orders/lookup/request', { email: 'buyer@example.test' }], + [HttpMethod.Post, '/api/v1/shop/orders/lookup/verify', { email: 'a@b.test', code: '123456' }], + [HttpMethod.Get, '/api/v1/shop/orders/SHOP-000001'], + [HttpMethod.Post, '/api/v1/shop/orders/SHOP-000001/pay'], + [HttpMethod.Post, '/api/v1/shop/consents', {}], + [HttpMethod.Delete, '/api/users/me'], + [HttpMethod.Post, '/api/users/me/restore'], +]; + +/** Существующие в API ресурсы, которые mock-server намеренно не моделирует. */ +const UNSUPPORTED: ReadonlyArray = [ + [HttpMethod.Post, '/api/posts/post-1/pin'], + [HttpMethod.Post, '/api/users/bob/block'], + [HttpMethod.Get, '/api/users/me/privacy'], + [HttpMethod.Get, '/api/notifications/settings'], +]; + +function makeContractServer() { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + ], + posts: [ + { id: 'post-1', authorId: BOB }, + { id: 'own-post', authorId: ALICE }, + ], + comments: [ + { id: 'comment-1', postId: 'post-1', authorId: BOB }, + { id: 'own-comment', postId: 'post-1', authorId: ALICE }, + ], + notifications: [{ id: 'notification-1', userId: ALICE, actorIds: [BOB] }], + }, + }); + const options = server.clientOptions({ as: 'alice' }); + const send = (method: string, path: string, body?: unknown) => + server.fetch(`${options.baseUrl}${path}`, { + method, + headers: { + authorization: `Bearer ${String(options.auth)}`, + 'content-type': 'application/json', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return { server, send }; +} + +describe('createMockServer: контракт маршрутов', () => { + it('обслуживает каждый встроенный маршрут без 501', async () => { + const { server, send } = makeContractServer(); + + for (const [method, path, body] of SUPPORTED) { + const response = await send(method, path, body); + expect.soft(response.status, `${method} ${path}`).not.toBe(501); + } + expect(server.unsupportedRequests).toEqual([]); + }); + + it.each(UNSUPPORTED)('явно не поддерживает %s %s', async (method, path) => { + const { server, send } = makeContractServer(); + + const response = await send(method, path); + + expect(response.status).toBe(501); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'MOCK_ROUTE_NOT_IMPLEMENTED' }, + }); + expect(() => server.assertNoUnsupportedRequests()).toThrow(path); + }); +}); diff --git a/packages/testing/test/server/search/search.routes.test.ts b/packages/testing/test/server/search/search.routes.test.ts new file mode 100644 index 0000000..dc02d0b --- /dev/null +++ b/packages/testing/test/server/search/search.routes.test.ts @@ -0,0 +1,19 @@ +import { ItdClient } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { makeServer } from '../test-server.utils.js'; + +describe('createMockServer: поиск', () => { + it('находит пользователей и хэштеги одним запросом', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + await alice.users.follow('bob'); + await alice.posts.create((post) => post.markup((m) => m.hashtag('бобр'))); + + await expect(alice.search.all('#боб')).resolves.toEqual({ + users: [expect.objectContaining({ username: 'bob', followersCount: 1, hasNuksta: false })], + hashtags: [{ id: 'hashtag-бобр', name: 'бобр', postsCount: 1 }], + }); + await expect(alice.search.all('')).resolves.toEqual({ users: [], hashtags: [] }); + server.assertNoUnsupportedRequests(); + }); +}); diff --git a/packages/testing/test/server/search/search.service.test.ts b/packages/testing/test/server/search/search.service.test.ts new file mode 100644 index 0000000..90292d8 --- /dev/null +++ b/packages/testing/test/server/search/search.service.test.ts @@ -0,0 +1,46 @@ +import { SpanType } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { makeRuntime } from '../test-runtime.utils.js'; +import { ALICE } from '../test-server.utils.js'; + +describe('SearchService', () => { + it('ищет пользователей по подстроке и хэштеги по началу имени', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'kiow', displayName: 'Автор' }, + { id: 'u2', username: 'zakiow', displayName: 'Заки' }, + { id: 'u3', username: 'kiowwi', displayName: 'Лина' }, + { id: 'u4', username: 'other', displayName: 'Тоже Kiow' }, + { id: 'u5', username: 'kiow_off', displayName: 'Ушёл', deactivated: true }, + ], + posts: [ + { + id: 'p1', + authorId: ALICE, + content: '#kiowdev', + spans: [{ type: SpanType.Hashtag, offset: 0, length: 8, tag: 'kiowdev' }], + }, + ], + }); + const results = runtime.services.search.search(' #KIOW '); + + expect(results.users.map((user) => user.profile.username)).toEqual([ + 'kiow', + 'kiowwi', + 'other', + 'zakiow', + ]); + expect(results.hashtags.map((hashtag) => hashtag.name)).toEqual(['kiowdev']); + }); + + it('отдаёт не больше пяти пользователей и пустой ответ на пустой запрос', () => { + const users = Array.from({ length: 7 }, (_, index) => ({ + id: `u${index}`, + username: `user_${index}`, + })); + const { search } = makeRuntime({ users }).services; + + expect(search.search('user').users).toHaveLength(5); + expect(search.search(' # ')).toEqual({ users: [], hashtags: [] }); + }); +}); diff --git a/packages/testing/test/server/shared/access.utils.test.ts b/packages/testing/test/server/shared/access.utils.test.ts new file mode 100644 index 0000000..9bf5cc0 --- /dev/null +++ b/packages/testing/test/server/shared/access.utils.test.ts @@ -0,0 +1,26 @@ +import { AccessType } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import type { AccessRelation } from '../../../src/server/shared/access.types.js'; +import { canAccess } from '../../../src/server/shared/access.utils.js'; + +const stranger: AccessRelation = { isOwner: false, follows: false, followedBy: false }; +const follower: AccessRelation = { isOwner: false, follows: true, followedBy: false }; +const followedOnly: AccessRelation = { isOwner: false, follows: false, followedBy: true }; +const mutual: AccessRelation = { isOwner: false, follows: true, followedBy: true }; +const owner: AccessRelation = { isOwner: true, follows: false, followedBy: false }; + +describe('canAccess', () => { + it.each([ + [AccessType.Everyone, [true, true, true, true, true]], + [AccessType.Followers, [false, true, false, true, true]], + [AccessType.Mutual, [false, false, false, true, true]], + [AccessType.Nobody, [false, false, false, false, true]], + ['unknown', [false, false, false, false, true]], + ])('применяет политику %s', (policy, expected) => { + expect( + [stranger, follower, followedOnly, mutual, owner].map((relation) => + canAccess(policy, relation), + ), + ).toEqual(expected); + }); +}); diff --git a/packages/testing/test/server/shared/pagination.utils.test.ts b/packages/testing/test/server/shared/pagination.utils.test.ts new file mode 100644 index 0000000..3aa4ad5 --- /dev/null +++ b/packages/testing/test/server/shared/pagination.utils.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { CursorSlice } from '../../../src/server/shared/pagination.types.js'; +import { + cursorPage, + numberedPage, + offsetPage, +} from '../../../src/server/shared/pagination.utils.js'; + +const items = Array.from({ length: 5 }, (_, index) => index + 1); + +describe('пагинация mock-server', () => { + it('проходит курсорный список без пропусков и повторов', () => { + const seen: number[] = []; + let cursor: string | null = null; + do { + const page: CursorSlice = cursorPage(items, { limit: 2, cursor }); + seen.push(...page.items); + cursor = page.nextCursor; + expect(page.hasMore).toBe(cursor !== null); + } while (cursor !== null); + + expect(seen).toEqual(items); + }); + + it('начинает с начала при некорректном курсоре', () => { + expect(cursorPage(items, { limit: 2, cursor: 'garbage' }).items).toEqual([1, 2]); + }); + + it('сообщает об окончании нумерованного списка', () => { + expect(numberedPage(items, { page: 3, limit: 2 })).toEqual({ + items: [5], + page: 3, + limit: 2, + total: 5, + hasMore: false, + }); + }); + + it('режет список по смещению', () => { + expect(offsetPage(items, { offset: 3, limit: 2 })).toEqual({ items: [4, 5], hasMore: false }); + expect(offsetPage(items, { offset: 0, limit: 2 }).hasMore).toBe(true); + }); +}); diff --git a/packages/testing/test/server/shop/shop.routes.test.ts b/packages/testing/test/server/shop/shop.routes.test.ts new file mode 100644 index 0000000..67f09ba --- /dev/null +++ b/packages/testing/test/server/shop/shop.routes.test.ts @@ -0,0 +1,135 @@ +import { createShopFeature, ItdClient } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { createMockServer, createTestClock, HttpMethod } from '../../../src/index.js'; + +describe('createMockServer: магазин', () => { + it('выполняет сценарий магазина с доступом по коду из письма', async () => { + const server = createMockServer({ + clock: createTestClock('2026-08-01T10:00:00Z'), + seed: { + shopProducts: [ + { + id: 'hoodie', + title: 'Худи', + category: 'apparel', + price: 4_000, + images: [], + sizes: ['M'], + colors: [], + description: '', + specs: [], + status: 'available', + stockLeft: 10, + }, + ], + }, + }); + const client = new ItdClient(server.clientOptions({ as: 'test_user_1' })); + const created = await client.shop.orders.create({ + items: [{ productId: 'hoodie', size: 'M', color: null, qty: 1 }], + recipient: { + name: 'Покупатель', + phone: '+70000000000', + email: 'buyer@example.test', + country: 'Россия', + city: 'Москва', + address: 'Тестовая улица, 1', + cityCode: 44, + deliveryPoint: '', + comment: '', + }, + consents: [], + consentContext: { form: 'checkout', page: '/shop', visitorId: 'visitor' }, + }); + expect(await client.shop.orders.get(created.number)).toMatchObject({ total: 4_500 }); + + const anonymous = new ItdClient({ + baseUrl: 'https://mock.itd.test', + fetch: server.fetch, + retry: false, + rateLimit: false, + userAgent: false, + }); + const access = anonymous.install(createShopFeature()); + await access.requestCode('buyer@example.test'); + await access.verifyCode('123456'); + await expect(access.list()).resolves.toMatchObject({ + items: [{ number: created.number }], + }); + server.assertNoUnsupportedRequests(); + }); + + it('отклоняет неизвестный и истёкший токены заказов', async () => { + const clock = createTestClock('2026-08-01T10:00:00Z'); + const server = createMockServer({ clock }); + const client = new ItdClient({ + baseUrl: 'https://mock.itd.test', + fetch: server.fetch, + retry: false, + rateLimit: false, + userAgent: false, + }); + + await expect( + client.shop.orders.list({ orderAccessToken: 'unknown', useItdAuth: false }), + ).rejects.toMatchObject({ status: 401 }); + + const email = 'buyer@example.test'; + await client.shop.orders.requestAccessCode(email); + const { token } = await client.shop.orders.verifyAccessCode(email, '123456'); + await clock.advanceBy(600_001); + + await expect( + client.shop.orders.list({ orderAccessToken: token, useItdAuth: false }), + ).rejects.toMatchObject({ status: 401 }); + server.assertNoUnsupportedRequests(); + }); + + it('отклоняет заказ без получателя ошибкой, а не сбоем сети', async () => { + const server = createMockServer(); + const client = new ItdClient(server.clientOptions({ as: 'test_user_1' })); + + await expect( + client.request({ method: HttpMethod.Post, path: '/api/v1/shop/orders', body: { items: [] } }), + ).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(server.snapshot().shopOrders).toEqual([]); + }); + + it.each([ + ['пустой список позиций', { items: [] }, 400], + ['нестроковый комментарий', { recipient: { comment: 7 } }, 400], + ['неизвестный товар', { items: [{ productId: 'nope', size: null, color: null, qty: 1 }] }, 404], + ])('отклоняет заказ: %s', async (_name, change, status) => { + const server = createMockServer({ + seed: { + shopProducts: [ + { + id: 'hoodie', + title: 'Худи', + category: 'apparel', + price: 4_000, + images: [], + sizes: ['M'], + colors: [], + description: '', + specs: [], + status: 'available', + stockLeft: 10, + }, + ], + }, + }); + const client = new ItdClient(server.clientOptions({ as: 'test_user_1' })); + const recipient = { email: 'buyer@example.test', city: 'Москва', address: 'Тестовая улица, 1' }; + const body = { + items: [{ productId: 'hoodie', size: 'M', color: null, qty: 1 }], + ...change, + recipient: { ...recipient, ...('recipient' in change ? change.recipient : {}) }, + }; + + await expect( + client.request({ method: HttpMethod.Post, path: '/api/v1/shop/orders', body }), + ).rejects.toMatchObject({ status }); + expect(server.snapshot().shopOrders).toEqual([]); + }); +}); diff --git a/packages/testing/test/server/social-flow.test.ts b/packages/testing/test/server/social-flow.test.ts new file mode 100644 index 0000000..f60545c --- /dev/null +++ b/packages/testing/test/server/social-flow.test.ts @@ -0,0 +1,129 @@ +import { FeedTab, ItdClient } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { createMockServer, createTestClock } from '../../src/index.js'; +import { ALICE, BOB, CAROL, makeServer } from './test-server.utils.js'; + +describe('createMockServer: социальный сценарий', () => { + it('выполняет пользовательский сценарий в общем состоянии', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await expect(alice.auth.check()).resolves.toMatchObject({ + authenticated: true, + banned: false, + user: { id: ALICE, username: 'alice', roles: ['user'] }, + }); + await alice.users.follow('bob'); + const post = await bob.posts.create({ content: 'Проверяем сервер' }); + await expect(bob.posts.update(post.id, { content: 'Проверяем обновление' })).resolves.toEqual({ + id: post.id, + content: 'Проверяем обновление', + spans: [], + updatedAt: expect.any(String), + }); + await alice.posts.like(post.id); + const comment = await alice.posts.comment(post.id, 'Работает'); + await expect(alice.comments.update(comment.id, 'Точно работает')).resolves.toEqual({ + id: comment.id, + content: 'Точно работает', + editedAt: expect.any(String), + }); + + expect((await bob.posts.get(post.id)).likesCount).toBe(1); + expect((await bob.posts.comments(post.id)).items).toMatchObject([{ id: comment.id }]); + expect((await bob.notifications.list()).items.map((item) => item.type)).toEqual([ + 'post_comment', + 'post_reaction', + 'follow', + ]); + expect(await bob.notifications.count()).toBe(3); + expect(server.snapshot().users.find((user) => user.id === ALICE)?.following).toEqual([BOB]); + + await bob.posts.remove(post.id); + await expect(alice.posts.get(post.id)).rejects.toMatchObject({ status: 404 }); + await expect(bob.posts.restore(post.id)).resolves.toBeUndefined(); + await expect(alice.posts.get(post.id)).resolves.toMatchObject({ id: post.id }); + server.assertNoUnsupportedRequests(); + }); + + it('проходит социальную цепочку тремя клиентами без собственных маршрутов', async () => { + const server = createMockServer({ + clock: createTestClock('2026-08-01T10:00:00Z'), + seed: { + users: [ + { id: ALICE, username: 'alice', displayName: 'Алиса', avatar: '🦎' }, + { id: BOB, username: 'bob', displayName: 'Боб', avatar: '🦎' }, + { id: CAROL, username: 'carol', displayName: 'Кэрол', avatar: '🍅' }, + ], + }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + const carol = new ItdClient(server.clientOptions({ as: CAROL })); + + // Переименование не создаёт неоднозначного имени, связи держатся на id. + await expect(bob.users.updateMe({ username: 'ALICE' })).rejects.toMatchObject({ + code: 'USERNAME_TAKEN', + }); + await alice.users.updateMe({ username: 'alice_new' }); + await expect(carol.users.checkUsername('alice')).resolves.toEqual({ available: true }); + + // Подписки и списки графа. + await bob.users.follow('alice_new'); + await carol.users.follow(ALICE); + expect((await carol.users.followers('alice_new')).items.map((user) => user.id)).toEqual([ + BOB, + CAROL, + ]); + expect((await carol.users.following('bob')).items).toEqual([ + expect.objectContaining({ id: ALICE, username: 'alice_new', isFollowing: true }), + ]); + + // Ленты подписок и клана. + const post = await alice.posts.create((p) => + p.markup((m) => m.text('Новость ').hashtag('итд')), + ); + expect((await bob.posts.list({ tab: FeedTab.Following })).items.map((p) => p.id)).toEqual([ + post.id, + ]); + expect((await bob.posts.list({ tab: FeedTab.Clan })).items.map((p) => p.id)).toEqual([post.id]); + expect((await carol.posts.list({ tab: FeedTab.Clan })).items).toEqual([]); + + // Реакция и список лайкнутых. + await carol.posts.like(post.id); + expect((await bob.posts.likedByUser(CAROL)).items.map((p) => p.id)).toEqual([post.id]); + + // Репост и его отмена. + const repost = await bob.posts.repost(post.id, 'Смотрите'); + expect(repost.originalPost).toMatchObject({ id: post.id, repostsCount: 1 }); + await expect(carol.posts.get(post.id)).resolves.toMatchObject({ + repostsCount: 1, + isReposted: false, + }); + await bob.posts.unrepost(post.id); + await expect(bob.posts.get(post.id)).resolves.toMatchObject({ + repostsCount: 0, + isReposted: false, + }); + + // Поиск пользователей и хэштегов. + const found = await carol.search.all('ali'); + expect(found.users.map((user) => user.username)).toEqual(['alice_new']); + expect((await carol.search.all('#ит')).hashtags).toEqual([ + { id: 'hashtag-итд', name: 'итд', postsCount: 1 }, + ]); + + // Уведомления автора с участниками под текущими именами. + const notifications = (await alice.notifications.list()).items; + expect(notifications.map((item) => item.type)).toEqual([ + 'post_repost', + 'post_reaction', + 'follow', + 'follow', + ]); + expect(notifications.at(-1)?.actors[0]?.username).toBe('bob'); + + server.assertNoUnsupportedRequests(); + }); +}); diff --git a/packages/testing/test/server/test-runtime.utils.ts b/packages/testing/test/server/test-runtime.utils.ts new file mode 100644 index 0000000..a4f4cd3 --- /dev/null +++ b/packages/testing/test/server/test-runtime.utils.ts @@ -0,0 +1,25 @@ +import { createTestClock } from '../../src/index.js'; +import { MockServerRuntime } from '../../src/server/server.runtime.js'; +import type { MockServerSeed } from '../../src/server/server.types.js'; +import { ALICE, BOB, CAROL } from './test-server.utils.js'; + +/** Доменный слой mock-server без HTTP, загруженный из seed. */ +export function makeRuntime(seed: MockServerSeed = {}): MockServerRuntime { + const runtime = new MockServerRuntime(createTestClock('2026-08-01T10:00:00Z')); + runtime.load({ + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + { id: CAROL, username: 'carol' }, + ], + ...seed, + }); + return runtime; +} + +/** Пользователь из загруженного seed. */ +export function userOf(runtime: MockServerRuntime, id: string) { + const user = runtime.services.users.get(id); + if (!user) throw new Error(`Нет пользователя ${id}`); + return user; +} diff --git a/packages/testing/test/server/test-server.utils.ts b/packages/testing/test/server/test-server.utils.ts new file mode 100644 index 0000000..5bf767c --- /dev/null +++ b/packages/testing/test/server/test-server.utils.ts @@ -0,0 +1,22 @@ +import { createMockServer, createTestClock } from '../../src/index.js'; + +export const ALICE = '00000000-0000-4000-8000-000000000001'; +export const BOB = '00000000-0000-4000-8000-000000000002'; +export const CAROL = '00000000-0000-4000-8000-000000000003'; + +export async function settleUntil(condition: () => boolean): Promise { + for (let index = 0; index < 100 && !condition(); index += 1) await Promise.resolve(); + if (!condition()) throw new Error('Условие не наступило'); +} + +export function makeServer() { + return createMockServer({ + clock: createTestClock('2026-08-01T10:00:00Z'), + seed: { + users: [ + { id: ALICE, username: 'alice', displayName: 'Алиса' }, + { id: BOB, username: 'bob', displayName: 'Боб' }, + ], + }, + }); +} diff --git a/packages/testing/test/server/users/users.routes.test.ts b/packages/testing/test/server/users/users.routes.test.ts new file mode 100644 index 0000000..2a68e2b --- /dev/null +++ b/packages/testing/test/server/users/users.routes.test.ts @@ -0,0 +1,304 @@ +import { ItdClient, UsernameUnavailableReason } from 'itd-api'; +import { describe, expect, it } from 'vitest'; +import { createMockServer, HttpMethod, waitForUpdate } from '../../../src/index.js'; +import { ALICE, BOB, CAROL, makeServer } from '../test-server.utils.js'; + +describe('createMockServer: пользователи', () => { + it('не дублирует уведомление и событие при повторной подписке', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const transport = server.notificationEvents({ as: 'bob' }); + const bob = new ItdClient({ + ...server.clientOptions({ as: 'bob' }), + events: { notifications: { transport, syncCount: false, jitter: 0 } }, + }); + const stream = bob.notifications.events; + const delivered: unknown[] = []; + stream.on('notification', (event) => delivered.push(event)); + await stream.connect(); + await transport.waitForConnection(0); + + const update = waitForUpdate(stream); + await alice.users.follow('bob'); + await update; + await alice.users.follow('bob'); + await stream.drain(); + + expect(await bob.notifications.count()).toBe(1); + expect(delivered).toHaveLength(1); + stream.disconnect(); + }); + + it('находит пользователя для клиента по id и текущему имени после переименования', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + await alice.users.updateMe({ username: 'alice_renamed' }); + + const byId = new ItdClient(server.clientOptions({ as: ALICE })); + const byName = new ItdClient(server.clientOptions({ as: 'alice_renamed' })); + await expect(byId.users.me()).resolves.toMatchObject({ username: 'alice_renamed' }); + await expect(byName.users.me()).resolves.toMatchObject({ id: ALICE }); + expect(() => server.clientOptions({ as: 'alice' })).toThrow(/id или текущим username alice/); + expect(() => server.notificationEvents({ as: 'alice' })).toThrow(/текущим username/); + }); + + it('показывает в уведомлении текущее имя участника', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + await alice.users.follow(BOB); + + await alice.users.updateMe({ username: 'alice_renamed' }); + + const [notification] = (await bob.notifications.list()).items; + expect(notification?.actors).toEqual([expect.objectContaining({ username: 'alice_renamed' })]); + expect(server.snapshot().notifications[0]?.actorIds).toEqual([ALICE]); + }); + + it('отвечает на неизвестного пользователя кодом NOT_FOUND', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect(alice.users.get('missing')).rejects.toMatchObject({ + status: 404, + code: 'NOT_FOUND', + }); + }); + + it('отклоняет занятое и неверное имя при обновлении профиля', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect(alice.users.updateMe({ username: 'Bob' })).rejects.toMatchObject({ + status: 409, + code: 'USERNAME_TAKEN', + }); + await expect(alice.users.updateMe({ username: 'a-b' })).rejects.toMatchObject({ + status: 400, + code: 'VALIDATION_ERROR', + }); + await expect(alice.users.updateMe({ username: 'Alice' })).resolves.toMatchObject({ + username: 'Alice', + }); + }); + + it('проверяет имя так же, как прод', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect(alice.users.checkUsername('free_name')).resolves.toEqual({ available: true }); + await expect(alice.users.checkUsername('BOB')).resolves.toEqual({ available: false }); + await expect(alice.users.checkUsername('alice')).resolves.toEqual({ available: false }); + await expect(alice.users.checkUsername('ab')).resolves.toEqual({ + available: false, + reason: UsernameUnavailableReason.InvalidFormat, + }); + await expect( + alice.request({ + method: HttpMethod.Get, + path: '/api/users/check-username', + query: { username: 'ab' }, + raw: true, + }), + ).resolves.toEqual({ available: false, reason: 'INVALID_FORMAT' }); + server.assertNoUnsupportedRequests(); + }); + + it('устанавливает и удаляет баннер, отклоняя неверный bannerId', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect(alice.users.updateMe({ bannerId: 'file-1' })).resolves.toMatchObject({ + banner: 'file-1', + }); + await expect(alice.users.removeBanner()).resolves.toMatchObject({ banner: null }); + await expect( + alice.request({ method: HttpMethod.Put, path: '/api/users/me', body: { bannerId: 5 } }), + ).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + }); + + it('отдаёт подписчиков и подписки относительно текущего пользователя', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', following: [BOB] }, + { id: BOB, username: 'bob' }, + { id: CAROL, username: 'carol', following: [BOB, ALICE] }, + ], + }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + const followers = await alice.users.followers('bob'); + expect(followers.items).toEqual([ + expect.objectContaining({ id: ALICE, username: 'alice', isFollowing: false }), + expect.objectContaining({ id: CAROL, username: 'carol', isFollowing: false }), + ]); + expect(followers).toMatchObject({ page: 1, total: 2, hasMore: false }); + + const following = await alice.users.following(CAROL); + expect(following.items.map((user) => [user.id, user.isFollowing])).toEqual([ + [BOB, true], + [ALICE, false], + ]); + await expect(alice.users.following('bob')).resolves.toMatchObject({ items: [], total: 0 }); + server.assertNoUnsupportedRequests(); + }); + + it('листает подписчиков по номеру страницы без пропусков и повторов', async () => { + const fans = Array.from({ length: 5 }, (_, index) => ({ + id: `fan_${index + 1}`, + username: `fan_${index + 1}`, + following: [ALICE], + })); + const server = createMockServer({ + seed: { users: [{ id: ALICE, username: 'alice' }, ...fans] }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + const pages = await Promise.all( + [1, 2, 3].map((page) => alice.users.followers('alice', { page, limit: 2 })), + ); + + expect(pages.flatMap((page) => page.items.map((user) => user.username))).toEqual( + fans.map((fan) => fan.username), + ); + expect(pages.map((page) => page.hasMore)).toEqual([true, true, false]); + expect(pages[2]).toMatchObject({ page: 3, total: 5 }); + await expect(alice.users.followers('alice', { limit: 50 })).resolves.toMatchObject({ + limit: 20, + }); + }); + + it('обновляет списки после подписки и отписки', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await alice.users.follow('bob'); + expect((await alice.users.followers('bob')).items.map((user) => user.id)).toEqual([ALICE]); + await alice.users.unfollow('bob'); + expect((await alice.users.following('alice')).items).toEqual([]); + }); + + it('отвечает на список неизвестного пользователя кодом NOT_FOUND', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect(alice.users.followers('missing')).rejects.toMatchObject({ + status: 404, + code: 'NOT_FOUND', + }); + }); + + it('отдаёт топ кланов', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', avatar: '🍅' }, + { id: BOB, username: 'bob', avatar: '🦎' }, + { id: CAROL, username: 'carol', avatar: '🦎' }, + ], + }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect(alice.users.topClans()).resolves.toEqual([ + { avatar: '🦎', memberCount: 2 }, + { avatar: '🍅', memberCount: 1 }, + ]); + server.assertNoUnsupportedRequests(); + }); + + it('хранит клан отдельно от аватара-картинки', async () => { + const server = createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice', avatar: 'https://cdn.test/alice.png', clanAvatar: '🦎' }, + { id: BOB, username: 'bob', avatar: '🦎' }, + { id: CAROL, username: 'carol', avatar: '🍅' }, + ], + }, + }); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect(alice.users.me()).resolves.toMatchObject({ clanAvatar: '🦎' }); + await expect(alice.users.get('bob')).resolves.toMatchObject({ clanAvatar: '🦎' }); + await expect(alice.auth.check()).resolves.toMatchObject({ user: { clanAvatar: '🦎' } }); + await expect(alice.users.topClans()).resolves.toEqual([ + { avatar: '🦎', memberCount: 2 }, + { avatar: '🍅', memberCount: 1 }, + ]); + + await alice.users.updateMe({ avatar: 'https://cdn.test/new.png' }); + await expect(alice.users.me()).resolves.toMatchObject({ clanAvatar: '🦎' }); + await alice.users.updateMe({ avatar: '🍅' }); + await expect(alice.users.me()).resolves.toMatchObject({ avatar: '🍅', clanAvatar: '🍅' }); + }); + + it('отклоняет поля профиля неверного типа', async () => { + const server = makeServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await expect( + alice.request({ method: HttpMethod.Put, path: '/api/users/me', body: { username: 123 } }), + ).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + await expect( + alice.request({ method: HttpMethod.Post, path: '/api/posts', body: { content: 42 } }), + ).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(server.snapshot().posts).toEqual([]); + }); +}); + +function makeDeactivationServer() { + return createMockServer({ + seed: { + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + ], + posts: [{ id: 'alice-post', authorId: ALICE }], + }, + }); +} + +describe('createMockServer: деактивация', () => { + it('разрешает деактивированному только чтение и восстановление', async () => { + const server = makeDeactivationServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + + await alice.users.deactivate(); + + await expect(alice.users.me()).resolves.toMatchObject({ id: ALICE }); + await expect(alice.auth.check()).resolves.toMatchObject({ authenticated: true }); + await expect(alice.posts.create({ content: 'Нельзя' })).rejects.toMatchObject({ + status: 403, + code: 'ACCOUNT_DEACTIVATED', + }); + await expect(alice.users.follow('bob')).rejects.toMatchObject({ status: 403 }); + + await alice.users.restore(); + await expect(alice.posts.create({ content: 'Можно' })).resolves.toMatchObject({ + content: 'Можно', + }); + server.assertNoUnsupportedRequests(); + }); + + it('скрывает деактивированного от других до восстановления', async () => { + const server = makeDeactivationServer(); + const alice = new ItdClient(server.clientOptions({ as: 'alice' })); + const bob = new ItdClient(server.clientOptions({ as: 'bob' })); + + await alice.users.deactivate(); + + const notFound = { status: 404, code: 'NOT_FOUND' }; + await expect(bob.users.get('alice')).rejects.toMatchObject(notFound); + await expect(bob.posts.byUser('alice')).rejects.toMatchObject(notFound); + await expect(bob.posts.get('alice-post')).rejects.toMatchObject(notFound); + await expect(bob.users.follow(ALICE)).rejects.toMatchObject(notFound); + expect((await bob.posts.list()).items).toEqual([]); + + await alice.users.restore(); + await expect(bob.users.get('alice')).resolves.toMatchObject({ id: ALICE }); + expect((await bob.posts.list()).items.map((post) => post.id)).toEqual(['alice-post']); + }); +}); diff --git a/packages/testing/test/server/users/users.service.test.ts b/packages/testing/test/server/users/users.service.test.ts new file mode 100644 index 0000000..ac479bd --- /dev/null +++ b/packages/testing/test/server/users/users.service.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { MockDomainError } from '../../../src/server/shared/domain.errors.js'; +import { UsernameIssue } from '../../../src/server/users/users.constants.js'; +import { makeRuntime, userOf } from '../test-runtime.utils.js'; +import { ALICE, BOB, CAROL } from '../test-server.utils.js'; + +describe('UserService', () => { + it('ищет по id раньше, чем по username', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob' }, + ], + }); + const { users } = runtime.services; + + expect(users.find(ALICE)?.profile.username).toBe('alice'); + expect(users.find('bob')?.profile.id).toBe(BOB); + expect(users.find('missing')).toBeUndefined(); + }); + + it('сообщает об отсутствующем пользователе ошибкой NOT_FOUND', () => { + const { users } = makeRuntime().services; + + expect(() => users.require('missing')).toThrow( + expect.objectContaining({ status: 404, code: 'NOT_FOUND' }), + ); + }); + + it('создаёт уведомление только при появлении подписки', () => { + const runtime = makeRuntime(); + const { users, notifications } = runtime.services; + const alice = userOf(runtime, ALICE); + const bob = userOf(runtime, BOB); + + users.follow(alice, bob); + users.follow(alice, bob); + expect(notifications.forUser(BOB)).toHaveLength(1); + expect(users.followersCount(bob)).toBe(1); + }); + + it('снимает подписку и спокойно принимает повторную отписку', () => { + const runtime = makeRuntime(); + const { users } = runtime.services; + const alice = userOf(runtime, ALICE); + const bob = userOf(runtime, BOB); + + users.follow(alice, bob); + users.unfollow(alice, bob); + users.unfollow(alice, bob); + expect(users.isFollowing(alice, bob)).toBe(false); + expect(users.followersCount(bob)).toBe(0); + }); + + it('запрещает подписку на себя', () => { + const runtime = makeRuntime(); + const alice = userOf(runtime, ALICE); + + expect(() => runtime.services.users.follow(alice, alice)).toThrow(MockDomainError); + }); + + it.each([['ab'], ['with-dash'], ['пользователь'], ['a'.repeat(33)]])( + 'отклоняет имя неверного формата: %s', + (username) => { + expect(makeRuntime().services.users.validateUsername(username)).toBe( + UsernameIssue.InvalidFormat, + ); + }, + ); + + it('считает занятым чужое имя и чужой id без учёта регистра', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice' }, + { id: 'dave_id', username: 'dave' }, + ], + }); + const { users } = runtime.services; + + expect(users.validateUsername('ALICE')).toBe(UsernameIssue.Taken); + expect(users.validateUsername('Dave_Id')).toBe(UsernameIssue.Taken); + expect(users.validateUsername('free_name')).toBeUndefined(); + }); + + it('разрешает владельцу оставить своё имя и сменить в нём регистр', () => { + const runtime = makeRuntime(); + const alice = userOf(runtime, ALICE); + + expect(runtime.services.users.validateUsername('alice', alice)).toBeUndefined(); + expect(runtime.services.users.validateUsername('Alice', alice)).toBeUndefined(); + }); + + it('не меняет профиль, если новое имя занято', () => { + const runtime = makeRuntime(); + const alice = userOf(runtime, ALICE); + + expect(() => + runtime.services.users.updateProfile(alice, { username: 'BOB', displayName: 'Новое' }), + ).toThrow(expect.objectContaining({ status: 409, code: 'USERNAME_TAKEN' })); + expect(alice.profile).toMatchObject({ username: 'alice' }); + expect(alice.profile.displayName).not.toBe('Новое'); + }); + + it('освобождает прежнее имя после переименования', () => { + const runtime = makeRuntime(); + const { users } = runtime.services; + + users.updateProfile(userOf(runtime, ALICE), { username: 'alice_new' }); + + expect(users.validateUsername('alice', userOf(runtime, BOB))).toBeUndefined(); + expect(users.find('alice')).toBeUndefined(); + }); + + it('различает подписчиков и подписки', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice', following: [BOB, CAROL] }, + { id: BOB, username: 'bob', following: [ALICE] }, + { id: CAROL, username: 'carol' }, + ], + }); + const { users } = runtime.services; + const ids = (list: { profile: { id: string } }[]) => list.map((user) => user.profile.id); + + expect(ids(users.following(userOf(runtime, ALICE)))).toEqual([BOB, CAROL]); + expect(ids(users.followers(userOf(runtime, ALICE)))).toEqual([BOB]); + expect(ids(users.followers(userOf(runtime, CAROL)))).toEqual([ALICE]); + expect(users.following(userOf(runtime, CAROL))).toEqual([]); + }); + + it('считает кланы по аватарам активных пользователей', () => { + const runtime = makeRuntime({ + users: [ + { id: ALICE, username: 'alice', avatar: '🦎' }, + { id: BOB, username: 'bob', avatar: '🦎' }, + { id: CAROL, username: 'carol', avatar: '🍅' }, + { id: 'dave_id', username: 'dave', avatar: '🍅', deactivated: true }, + { id: 'erin_id', username: 'erin', avatar: '🍌' }, + ], + }); + + expect(runtime.services.users.topClans(10)).toEqual([ + { avatar: '🦎', memberCount: 2 }, + { avatar: '🍅', memberCount: 1 }, + { avatar: '🍌', memberCount: 1 }, + ]); + expect(runtime.services.users.topClans(1)).toHaveLength(1); + }); +}); + +function makeDeactivatedRuntime() { + return makeRuntime({ + users: [ + { id: ALICE, username: 'alice' }, + { id: BOB, username: 'bob', deactivated: true }, + { id: CAROL, username: 'carol' }, + ], + posts: [ + { id: 'alice-post', authorId: ALICE }, + { id: 'bob-post', authorId: BOB }, + { id: 'bob-repost', authorId: BOB, originalPostId: 'alice-post' }, + ], + comments: [ + { id: 'alice-comment', postId: 'alice-post', authorId: ALICE }, + { id: 'bob-comment', postId: 'alice-post', authorId: BOB }, + ], + }); +} + +describe('деактивированный пользователь', () => { + it('недоступен другим по id и username', () => { + const { users } = makeDeactivatedRuntime().services; + + for (const reference of [BOB, 'bob']) { + expect(() => users.require(reference)).toThrow( + expect.objectContaining({ status: 404, code: 'NOT_FOUND' }), + ); + } + expect(users.find('bob')?.deactivated).toBe(true); + expect([users.isActive(ALICE), users.isActive(BOB), users.isActive('missing')]).toEqual([ + true, + false, + false, + ]); + }); + + it('скрывает его посты, репосты и комментарии', () => { + const runtime = makeDeactivatedRuntime(); + const { posts, comments } = runtime.services; + const alicePost = posts.requireActive('alice-post'); + + expect(posts.feed().map((post) => post.id)).toEqual(['alice-post']); + expect(() => posts.requireActive('bob-post')).toThrow(expect.objectContaining({ status: 404 })); + expect(posts.repostsCount(alicePost)).toBe(0); + expect(comments.topLevel(alicePost).map((comment) => comment.id)).toEqual(['alice-comment']); + expect(comments.activeCountFor(alicePost)).toBe(1); + expect(() => comments.requireActive('bob-comment')).toThrow( + expect.objectContaining({ status: 404 }), + ); + }); + + it('возвращает контент после восстановления', () => { + const runtime = makeDeactivatedRuntime(); + const { posts, users } = runtime.services; + + users.restore(userOf(runtime, BOB)); + + expect(posts.feed()).toHaveLength(3); + expect(posts.repostsCount(posts.requireActive('alice-post'))).toBe(1); + }); + + it('не получает ответы на комментарии', () => { + const runtime = makeDeactivatedRuntime(); + const { comments } = runtime.services; + + expect(() => + comments.reply(comments.requireActive('alice-comment'), userOf(runtime, CAROL), '', BOB), + ).toThrow(expect.objectContaining({ status: 404, code: 'NOT_FOUND' })); + }); +}); diff --git a/src/domain/posts.ts b/src/domain/posts.ts new file mode 100644 index 0000000..5b904bc --- /dev/null +++ b/src/domain/posts.ts @@ -0,0 +1,33 @@ +import type { Comment, OriginalPost, Post } from '../models/content.js'; +import { utcStampToIso } from './time.js'; + +/** Поле с датой, приведённой к ISO-8601; без строкового значения поле не появляется. */ +function isoField(key: K, value: unknown): Partial> { + return typeof value === 'string' ? ({ [key]: utcStampToIso(value) } as Record) : {}; +} + +/** Приводит даты комментария и его ответов к ISO-8601. */ +function normalizeComment(comment: Comment): Comment { + if (typeof comment !== 'object' || comment === null) return comment; + return { + ...comment, + ...isoField('createdAt', comment.createdAt), + ...(Array.isArray(comment.replies) ? { replies: comment.replies.map(normalizeComment) } : {}), + }; +} + +function normalizeOriginalPost(post: OriginalPost): OriginalPost { + return { ...post, ...isoField('createdAt', post.createdAt) }; +} + +/** Приводит даты поста, исходного поста репоста и вложенных комментариев к ISO-8601. */ +export function normalizePost(post: Post): Post { + if (typeof post !== 'object' || post === null) return post; + return { + ...post, + ...isoField('createdAt', post.createdAt), + ...isoField('editedAt', post.editedAt), + ...(post.originalPost ? { originalPost: normalizeOriginalPost(post.originalPost) } : {}), + ...(Array.isArray(post.comments) ? { comments: post.comments.map(normalizeComment) } : {}), + }; +} diff --git a/src/domain/time.ts b/src/domain/time.ts index 143d04d..978f39c 100644 --- a/src/domain/time.ts +++ b/src/domain/time.ts @@ -1,25 +1,45 @@ -/** Отметка времени без часового пояса: `2026-07-23 23:14:25`, возможно с долями секунды. */ import type { IsoDate } from '../models/common.js'; -const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/; +/** + * Отметка времени PostgreSQL: `2026-07-23 23:14:25`, возможно с долями секунды и смещением + * пояса (`+03`, `+05:30`). + */ +const PG_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?(?:([+-]\d{2})(?::?(\d{2}))?)?$/; + +/** Существует ли дата `ГГГГ-ММ-ДД` в календаре: `Date.parse` молча переносит `02-30` на март. */ +function isCalendarDate(date: string): boolean { + const [year = 0, month = 0, day = 0] = date.split('-').map(Number); + const parsed = new Date(Date.UTC(year, month - 1, day)); + return parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day; +} /** - * Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC. + * Приводит отметку времени PostgreSQL к ISO-8601 в UTC. * - * Строку другого вида возвращает нетронутой. + * Отметку без пояса считает временем UTC и сохраняет её доли секунды. Отметку со смещением + * переводит в UTC с точностью до миллисекунд. Строку другого вида возвращает нетронутой. * * @example * ```ts - * utcStampToIso('2026-07-23 23:14:25'); // '2026-07-23T23:14:25Z' - * utcStampToIso('2026-07-23T23:14:25Z'); // без изменений + * utcStampToIso('2026-07-23 23:14:25'); // '2026-07-23T23:14:25Z' + * utcStampToIso('2026-10-04 22:32:04.381097+03'); // '2026-10-04T19:32:04.381Z' + * utcStampToIso('2026-07-23T23:14:25Z'); // без изменений * ``` */ export function utcStampToIso(value: string): string { - const match = typeof value === 'string' ? NAIVE_STAMP.exec(value) : null; + const match = typeof value === 'string' ? PG_STAMP.exec(value) : null; if (!match) return value; + const [, date = '', time, fraction = '', offsetHours, offsetMinutes = '00'] = match; + if (!isCalendarDate(date)) return value; + + if (offsetHours === undefined) { + const iso = `${date}T${time}${fraction}Z`; + return Number.isFinite(Date.parse(iso)) ? iso : value; + } - const iso = `${match[1]}T${match[2]}Z`; - return Number.isFinite(Date.parse(iso)) ? iso : value; + const millis = fraction ? `.${fraction.slice(1, 4).padEnd(3, '0')}` : ''; + const parsed = Date.parse(`${date}T${time}${millis}${offsetHours}:${offsetMinutes}`); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value; } /** diff --git a/src/index.ts b/src/index.ts index fe8e0fe..b0b0643 100644 --- a/src/index.ts +++ b/src/index.ts @@ -283,6 +283,7 @@ export type { CommentUpdateResult, Hashtag, LikeResult, + OriginalPost, PinPostResult, Poll, PollOption, @@ -351,6 +352,7 @@ export type { Profile, PublicProfile, SubscriptionState, + UsernameAvailability, UserSummary, } from './models/users.js'; // Уведомления приводятся к единой форме, поэтому объекты из REST и из потока событий @@ -509,12 +511,14 @@ export { InteractionType, ItdErrorCode, LikesVisibility, + MessageAccess, NotificationType, QrLocationPrecision, ReportReason, ReportTargetType, ServiceState, SpanType, + UsernameUnavailableReason, ViewReason, ViewSource, WallAccess, diff --git a/src/models/content.ts b/src/models/content.ts index 4e7dbea..1778477 100644 --- a/src/models/content.ts +++ b/src/models/content.ts @@ -51,6 +51,30 @@ export interface Poll { createdAt: IsoDate; } +/** + * Пост, на который ссылается репост. Сервер отдаёт его урезанным и только на один уровень: + * без признаков относительно текущего пользователя и без собственного `originalPost`. + */ +export interface OriginalPost { + id: string; + content: string; + /** Разметка текста, см. {@link Span}. */ + spans: Span[]; + author: Author; + attachments: Attachment[]; + likesCount: number; + commentsCount: number; + repostsCount: number; + viewsCount: number; + /** Удалён ли пост. */ + isDeleted: boolean; + /** Преобладающая реакция — эмодзи либо `null`. */ + dominantEmoji?: string | null; + createdAt: IsoDate; + /** Служебная метка показа для телеметрии, см. {@link Post.vs}. */ + vs?: string; +} + /** Пост ленты, стены или профиля. */ export interface Post { id: string; @@ -75,8 +99,8 @@ export interface Post { isViewed?: boolean; /** Ваш ли это пост. */ isOwner: boolean; - /** Исходный пост, если это репост. */ - originalPost?: Post | null; + /** Пост, который репостнули, если это репост. `null` у обычного поста. */ + originalPost?: OriginalPost | null; poll?: Poll | null; /** Преобладающая реакция — эмодзи либо `null`. */ dominantEmoji?: string | null; diff --git a/src/models/notifications.ts b/src/models/notifications.ts index 06d7037..7b40c53 100644 --- a/src/models/notifications.ts +++ b/src/models/notifications.ts @@ -39,18 +39,14 @@ export interface Notification { raw: unknown; } -/** - * Настройки уведомлений. - * - * Сервер отдаёт плоский объект, но исторически знает два набора имён для одних и тех же - * настроек (`likes` и `reactions`, `comments` и `replies`). При сохранении библиотека - * отправляет оба, при чтении принимает любой. - */ +/** Настройки уведомлений. Сервер отдаёт их плоским объектом. */ export interface NotificationSettings { /** Общий выключатель доставки. */ enabled: boolean; /** Звук уведомления. */ sound: boolean; + /** Личные сообщения. */ + messages: boolean; /** Новые подписчики. */ follows: boolean; /** Записи на вашей стене. */ diff --git a/src/models/platform.ts b/src/models/platform.ts index ad1a20b..c9a3b37 100644 --- a/src/models/platform.ts +++ b/src/models/platform.ts @@ -3,7 +3,7 @@ import type { IsoDate } from './common.js'; /** Клан в рейтинге. */ export interface Clan { - /** Эмодзи клана — оно же аватар его участников. */ + /** Эмодзи клана — оно же `clanAvatar` его участников. */ avatar: string; memberCount: number; } diff --git a/src/models/users.ts b/src/models/users.ts index 32c3ff7..26791c6 100644 --- a/src/models/users.ts +++ b/src/models/users.ts @@ -1,4 +1,10 @@ -import type { LikesVisibility, Loose, WallAccess } from '../types/enums.js'; +import type { + LikesVisibility, + Loose, + MessageAccess, + UsernameUnavailableReason, + WallAccess, +} from '../types/enums.js'; import type { IsoDate, UserId } from './common.js'; /** Значок-«пин» в профиле — награда или отметка платформы. */ @@ -25,10 +31,10 @@ export interface Author { username: string; displayName: string; /** - * **Эмодзи, а не картинка.** + * Аватар: обычно эмодзи — символ клана (`🩵`, `🦎`), который отрисовывают как текст. * - * На итд.com аватар — это символ клана (`🩵`, `🦎`), а не адрес изображения. - * Отрисовывать его нужно как текст. + * Может быть и адресом изображения. Клан пользователя в этом случае хранится отдельно, + * в `clanAvatar` профиля. */ avatar: string; /** Пройдена ли верификация. */ @@ -48,7 +54,7 @@ export interface Actor { id: UserId; username: string; displayName: string; - /** Эмодзи-аватар, см. {@link Author.avatar}. */ + /** Аватар: эмодзи или адрес изображения, см. {@link Author.avatar}. */ avatar: string; /** Подписаны ли вы на этого пользователя. */ isFollowing?: boolean; @@ -67,7 +73,7 @@ export interface UserSummary { id: UserId; username: string; displayName: string; - /** Эмодзи-аватар, см. {@link Author.avatar}. */ + /** Аватар: эмодзи или адрес изображения, см. {@link Author.avatar}. */ avatar: string; verified: boolean; /** Подписаны ли вы. Приходит в списках подписчиков и подписок. */ @@ -83,8 +89,13 @@ interface ProfileBase { id: UserId; username: string; displayName: string; - /** Эмодзи-аватар, см. {@link Author.avatar}. */ + /** Аватар: эмодзи или адрес изображения, см. {@link Author.avatar}. */ avatar: string; + /** + * Эмодзи клана. Совпадает с `avatar`, пока аватар — эмодзи; при аватаре-картинке остаётся + * символом клана. + */ + clanAvatar?: string; /** URL изображения баннера либо `null`. */ banner: string | null; /** Описание профиля. */ @@ -133,8 +144,10 @@ export interface AuthUser { id: UserId; username: string; displayName: string; - /** Эмодзи-аватар, см. {@link Author.avatar}. */ + /** Аватар: эмодзи или адрес изображения, см. {@link Author.avatar}. */ avatar: string; + /** Эмодзи клана. Совпадает с `avatar`, пока аватар — эмодзи. */ + clanAvatar?: string; bio: string; verified: boolean; isPhoneVerified: boolean; @@ -175,12 +188,22 @@ export interface PublicProfile extends ProfileBase { /** Профиль: свой либо чужой. Различаются функцией `isMyProfile()`. */ export type Profile = MyProfile | PublicProfile; +/** Результат проверки имени пользователя. */ +export interface UsernameAvailability { + /** Свободно ли имя. Своё текущее имя сервер считает занятым. */ + available: boolean; + /** Причина, если имя не подходит по формату. У занятого имени причины нет. */ + reason?: UsernameUnavailableReason; +} + /** Настройки приватности профиля. */ export interface PrivacySettings { /** Закрыт ли профиль: подписка требует одобрения. */ isPrivate: boolean; wallAccess: WallAccess; likesVisibility: LikesVisibility; + /** Кто может писать личные сообщения. */ + messageAccess: MessageAccess; /** Показывать ли время последнего посещения. */ showLastSeen: boolean; } diff --git a/src/resources/hashtags.ts b/src/resources/hashtags.ts index e35defa..08933a5 100644 --- a/src/resources/hashtags.ts +++ b/src/resources/hashtags.ts @@ -4,17 +4,10 @@ import { encodePathSegment } from '../core/url.js'; import { defineBuiltInOperation } from '../domain/operations.js'; import type { Hashtag, Post } from '../models/content.js'; import { BaseResource } from './base.js'; -import { - type Page, - PaginationMode, - type Paginator, - pageOperation, - readCursorPage, -} from './pagination.js'; +import { type Page, PaginationMode, type Paginator } from './pagination.js'; +import { postPageOperation } from './post-operations.js'; -const HASHTAG_POSTS = pageOperation('hashtags.posts', (body) => - readCursorPage(body, 'posts'), -); +const HASHTAG_POSTS = postPageOperation('hashtags.posts'); const HASHTAGS_TRENDING = defineBuiltInOperation('hashtags.trending', (body) => pickArray(body, 'hashtags'), ); diff --git a/src/resources/notifications.ts b/src/resources/notifications.ts index 1f62d0c..a4b1e10 100644 --- a/src/resources/notifications.ts +++ b/src/resources/notifications.ts @@ -44,6 +44,7 @@ const NOTIFICATIONS_UPDATE_SETTINGS = defineBuiltInOperation; /** * Читает настройки уведомлений. * - * Сервер отдаёт плоский объект: `enabled`, `sound`, `follows`, `wallPosts`, `likes`, - * `comments`, `mentions`. Отсутствующая настройка считается включённой — так же + * Сервер отдаёт плоский объект: `enabled`, `sound`, `messages`, `follows`, `wallPosts`, + * `likes`, `comments`, `mentions`. Отсутствующая настройка считается включённой — так же * ведёт себя сайт итд.com. */ function readSettings(body: unknown): NotificationSettings { diff --git a/src/resources/post-operations.ts b/src/resources/post-operations.ts new file mode 100644 index 0000000..73cd5f0 --- /dev/null +++ b/src/resources/post-operations.ts @@ -0,0 +1,16 @@ +import { type BuiltInOperationId, defineBuiltInOperation } from '../domain/operations.js'; +import { normalizePost } from '../domain/posts.js'; +import type { Post } from '../models/content.js'; +import { mapPage, pageOperation, readCursorPage } from './pagination.js'; + +/** Операция, которая возвращает один пост. */ +export function postOperation(id: TId) { + return defineBuiltInOperation(id, (body) => normalizePost(body as Post)); +} + +/** Операция, которая возвращает курсорную страницу постов из поля `posts`. */ +export function postPageOperation(id: TId) { + return pageOperation(id, (body) => + mapPage(readCursorPage(body, 'posts'), normalizePost), + ); +} diff --git a/src/resources/posts.ts b/src/resources/posts.ts index 247400a..2bb220f 100644 --- a/src/resources/posts.ts +++ b/src/resources/posts.ts @@ -30,30 +30,26 @@ import { PaginationMode, type Paginator, pageOperation, - readCursorPage, readFlatCursorPage, } from './pagination.js'; +import { postOperation, postPageOperation } from './post-operations.js'; -const POSTS_LIST = pageOperation('posts.list', (body) => readCursorPage(body, 'posts')); -const POSTS_BY_USER = pageOperation('posts.byUser', (body) => - readCursorPage(body, 'posts'), -); -const POSTS_LIKED_BY_USER = pageOperation('posts.likedByUser', (body) => - readCursorPage(body, 'posts'), -); +const POSTS_LIST = postPageOperation('posts.list'); +const POSTS_BY_USER = postPageOperation('posts.byUser'); +const POSTS_LIKED_BY_USER = postPageOperation('posts.likedByUser'); const POSTS_COMMENTS = pageOperation('posts.comments', (body) => readFlatCursorPage(body, 'comments'), ); const POSTS_STATS = defineBuiltInOperation('posts.stats', (body) => pickArray(body, 'posts'), ); -const POSTS_CREATE = passthroughOperation('posts.create'); -const POSTS_GET = passthroughOperation('posts.get'); +const POSTS_CREATE = postOperation('posts.create'); +const POSTS_GET = postOperation('posts.get'); const POSTS_UPDATE = passthroughOperation('posts.update'); const POSTS_RESTORE = voidOperation('posts.restore'); const POSTS_LIKE = passthroughOperation('posts.like'); const POSTS_UNLIKE = passthroughOperation('posts.unlike'); -const POSTS_REPOST = passthroughOperation('posts.repost'); +const POSTS_REPOST = postOperation('posts.repost'); const POSTS_PIN = passthroughOperation('posts.pin'); const POSTS_UNPIN = passthroughOperation('posts.unpin'); const POSTS_VOTE = passthroughOperation('posts.vote'); diff --git a/src/resources/users.ts b/src/resources/users.ts index e3c5f87..9742340 100644 --- a/src/resources/users.ts +++ b/src/resources/users.ts @@ -14,6 +14,7 @@ import type { PinsResult, PrivacySettings, PublicProfile, + UsernameAvailability, UserSummary, } from '../models/users.js'; import { passthroughOperation, voidOperation } from '../operations/common.js'; @@ -39,8 +40,12 @@ const USER_LIST_OPERATIONS = { 'users.following': USERS_FOLLOWING, 'users.blocked': USERS_BLOCKED, } as const; -const USERS_CHECK_USERNAME = defineBuiltInOperation('users.checkUsername', (body) => - pickBoolean(body, 'available'), +const USERS_CHECK_USERNAME = defineBuiltInOperation( + 'users.checkUsername', + (body) => { + const reason = pickString(body, 'reason'); + return { available: pickBoolean(body, 'available'), ...(reason ? { reason } : {}) }; + }, ); const USERS_WHO_TO_FOLLOW = defineBuiltInOperation('users.whoToFollow', (body) => pickArray(body, 'users'), @@ -71,13 +76,13 @@ const USERS_REMOVE_PIN = voidOperation('users.removePin'); /** * Параметры списков пользователей. * - * ⚠️ Списки подписчиков, подписок и заблокированных на сервере **не листаются**: - * `page` он игнорирует, а `limit` зажимает на 20. Подробности — в {@link UsersResource.followers}. + * ⚠️ Сервер листает только свои списки: у чужого профиля `page` игнорируется, а `limit` + * зажимается до 20. Подробности — в {@link UsersResource.followers}. */ export interface UserListParams { /** Сколько записей вернуть. Значения больше 20 сервер молча уменьшает до 20. */ limit?: number; - /** Номер страницы. Сервер его игнорирует — оставлен на случай, если пагинацию починят. */ + /** Номер страницы, начиная с 1. Для чужого профиля сервер его игнорирует. */ page?: number; } @@ -231,8 +236,16 @@ export class UsersResource extends BaseResource { }); } - /** Проверяет, свободно ли имя пользователя. */ - checkUsername(username: string, options: RequestOptions = {}): Promise { + /** + * Проверяет, свободно ли имя пользователя. + * + * @example + * ```ts + * const { available, reason } = await itd.users.checkUsername('new_name'); + * if (reason === UsernameUnavailableReason.InvalidFormat) console.log('Неверный формат'); + * ``` + */ + checkUsername(username: string, options: RequestOptions = {}): Promise { return this.http.execute(USERS_CHECK_USERNAME, { path: '/api/users/check-username', query: { username }, @@ -280,10 +293,11 @@ export class UsersResource extends BaseResource { /** * Загружает подписчиков пользователя. * - * ⚠️ **Сервер этот список не листает.** Возвращаются первые 20 записей и только они: + * Свои подписчики листаются обычным образом: `page`, `limit` до 20, `hasMore` и `total`. + * + * ⚠️ **Чужой список сервер не листает.** Возвращаются первые 20 записей и только они: * параметр `page` игнорируется (любая страница отдаёт те же записи и `pagination.page: 1`), - * `limit` больше 20 молча уменьшается, а `hasMore` всегда `false`. Последнее честно — - * получить продолжение нечем. + * `limit` больше 20 молча уменьшается, а `hasMore` всегда `false`. * * Числу `total` доверять тоже не стоит: оно расходится с `followersCount` из профиля — * на проверенных аккаунтах занижено примерно на 1–4%. @@ -304,8 +318,8 @@ export class UsersResource extends BaseResource { /** * Перебирает подписчиков. * - * ⚠️ Перебор закончится после первых 20 записей: сервер список не листает — - * см. {@link followers}. Метод оставлен на случай, если пагинацию починят. + * Свой список перебирается целиком. Перебор чужого закончится после первых 20 записей: + * сервер его не листает — см. {@link followers}. */ iterateFollowers( user: UserRef, @@ -334,7 +348,7 @@ export class UsersResource extends BaseResource { ); } - /** Перебирает подписки. Закончится после первых 20 записей — см. {@link followers}. */ + /** Перебирает подписки. Чужой список закончится после первых 20 записей — см. {@link followers}. */ iterateFollowing( user: UserRef, params: UserListParams = {}, @@ -384,12 +398,12 @@ export class UsersResource extends BaseResource { }); } - /** Загружает заблокированных пользователей. Ограничения те же, что у {@link followers}. */ + /** Загружает заблокированных пользователей. Параметры — как у {@link followers}. */ blocked(params: UserListParams = {}, options: RequestOptions = {}): Promise> { return this.#userPage('users.blocked', '/api/users/me/blocked', params, options); } - /** Перебирает заблокированных. Закончится после первых 20 записей — см. {@link followers}. */ + /** Перебирает заблокированных. Параметры — как у {@link followers}. */ iterateBlocked( params: UserListParams = {}, options: PaginationOptions = {}, diff --git a/src/rest.ts b/src/rest.ts index 53f10dc..a588027 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -274,6 +274,7 @@ export type { Profile, PublicProfile, SubscriptionState, + UsernameAvailability, UserSummary, } from './models/users.js'; export { type NotificationEvent, normalizeNotification } from './notifications/normalize.js'; @@ -363,11 +364,13 @@ export { InteractionType, ItdErrorCode, LikesVisibility, + MessageAccess, NotificationType, ReportReason, ReportTargetType, ServiceState, SpanType, + UsernameUnavailableReason, ViewReason, ViewSource, WallAccess, diff --git a/src/types/enums.ts b/src/types/enums.ts index a030b5c..dfbbdb7 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -39,7 +39,7 @@ export type Loose = T | (string & {}); * Множество закрытое: неизвестное значение сервер отвергнет. */ export const FeedTab = Object.freeze({ - /** Популярное. Курсор здесь — номер страницы в виде строки (`"2"`, `"6"`…). */ + /** Популярное. Курсор здесь — числовая строка (`"40"`, `"60"`…), а не номер страницы. */ Popular: 'popular', /** Записи тех, на кого вы подписаны. Курсор — отметка времени последнего поста. */ Following: 'following', @@ -209,7 +209,8 @@ export type IncidentKind = Loose<(typeof IncidentKind)[keyof typeof IncidentKind /** * Уровень доступа к разделу профиля. * - * Общий набор значений для полей `wallAccess` и `likesVisibility` настроек приватности. + * Общий набор значений для полей `wallAccess`, `likesVisibility` и `messageAccess` настроек + * приватности. * Тип открытый: сервер может прислать значение вне этого перечня. */ export const AccessType = Object.freeze({ @@ -232,6 +233,19 @@ export type WallAccess = AccessType; export const LikesVisibility = AccessType; export type LikesVisibility = AccessType; +/** Почему имя пользователя нельзя занять. Тип открытый: сервер может прислать другую причину. */ +export const UsernameUnavailableReason = Object.freeze({ + /** Имя не подходит под правила: латинские буквы, цифры и `_`, не короче трёх символов. */ + InvalidFormat: 'INVALID_FORMAT', +} as const); +export type UsernameUnavailableReason = Loose< + (typeof UsernameUnavailableReason)[keyof typeof UsernameUnavailableReason] +>; + +/** Кто может писать пользователю личные сообщения. Псевдоним {@link AccessType}. */ +export const MessageAccess = AccessType; +export type MessageAccess = AccessType; + /** * Канонический тип уведомления (новое поколение имён). * @@ -375,6 +389,7 @@ export const ItdErrorCode = Object.freeze({ QR_APPROVER_NOT_ALLOWED: 'QR_APPROVER_NOT_ALLOWED', USER_INACTIVE: 'USER_INACTIVE', TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS', + USERNAME_TAKEN: 'USERNAME_TAKEN', PROFILE_USERNAME_TAKEN: 'PROFILE_USERNAME_TAKEN', PROFILE_USERNAME_RESERVED: 'PROFILE_USERNAME_RESERVED', PROFILE_RESTRICTION_ACTIVE: 'PROFILE_RESTRICTION_ACTIVE', diff --git a/test/client/client.test.ts b/test/client/client.test.ts index 66b42fd..598d8a9 100644 --- a/test/client/client.test.ts +++ b/test/client/client.test.ts @@ -22,7 +22,7 @@ import type { } from '../../src/index.js'; import type { ItdClientOptions } from '../../src/options.js'; import { TelemetryResource } from '../../src/resources/telemetry.js'; -import { CaptchaType } from '../../src/types/enums.js'; +import { CaptchaType, UsernameUnavailableReason } from '../../src/types/enums.js'; import { makeJwt } from '../helpers/jwt.js'; import { abortError, @@ -655,10 +655,33 @@ describe('пользователи', () => { expect(mock.calls[1]?.url).toContain('page=2'); }); - it('читает признак доступности имени', async () => { - const { itd } = makeClient([json({ available: true })]); + it('приводит дату исходного поста в репосте к ISO', async () => { + const repost = { + id: 'repost', + originalPost: { id: 'root', createdAt: '2026-10-04 22:32:04.381097+03' }, + }; + const { itd } = makeClient([json(repost), json({ posts: [repost], pagination: {} })]); + + const single = await itd.posts.get('repost'); + const page = await itd.posts.list(); + + expect(single.originalPost?.createdAt).toBe('2026-10-04T19:32:04.381Z'); + expect(page.items[0]?.originalPost?.createdAt).toBe('2026-10-04T19:32:04.381Z'); + }); - expect(await itd.users.checkUsername('новое_имя')).toBe(true); + it('читает признак доступности имени и причину отказа', async () => { + const { itd } = makeClient([ + json({ available: true }), + json({ available: false }), + json({ available: false, reason: 'INVALID_FORMAT' }), + ]); + + expect(await itd.users.checkUsername('новое_имя')).toEqual({ available: true }); + expect(await itd.users.checkUsername('kiow')).toEqual({ available: false }); + expect(await itd.users.checkUsername('ab')).toEqual({ + available: false, + reason: UsernameUnavailableReason.InvalidFormat, + }); }); it('читает активный значок как строку', async () => { diff --git a/test/domain/posts.test.ts b/test/domain/posts.test.ts new file mode 100644 index 0000000..f33319d --- /dev/null +++ b/test/domain/posts.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { normalizePost } from '../../src/domain/posts.js'; +import type { OriginalPost, Post } from '../../src/models/content.js'; + +function post(originalPost: OriginalPost | null): Post { + return { + id: 'repost', + content: '', + spans: [], + author: { id: 'u1', username: 'alice', displayName: 'Алиса', avatar: '🦎', verified: false }, + attachments: [], + likesCount: 0, + commentsCount: 0, + repostsCount: 0, + viewsCount: 0, + wallRecipientId: null, + isLiked: false, + isReposted: false, + isOwner: false, + originalPost, + createdAt: '2026-10-04T19:40:00.000Z', + }; +} + +function original(createdAt: string): OriginalPost { + return { + id: 'root', + content: 'Исходный', + spans: [], + author: { id: 'u2', username: 'bob', displayName: 'Боб', avatar: '🍅', verified: false }, + attachments: [], + likesCount: 0, + commentsCount: 0, + repostsCount: 1, + viewsCount: 0, + isDeleted: false, + createdAt, + }; +} + +describe('normalizePost', () => { + it('приводит даты поста, исходного поста и комментариев к ISO', () => { + const comment = { + id: 'c1', + content: '', + author: { id: 'u2', username: 'bob', displayName: 'Боб', avatar: '🍅', verified: false }, + likesCount: 0, + isLiked: false, + createdAt: '2026-10-04 22:00:00.5+03', + replies: [ + { + id: 'c2', + content: '', + author: { + id: 'u1', + username: 'alice', + displayName: 'Алиса', + avatar: '🦎', + verified: false, + }, + likesCount: 0, + isLiked: false, + createdAt: '2026-10-04 22:01:00+03', + }, + ], + }; + const source: Post = { + ...post(original('2026-10-04 22:32:04.381097+03')), + createdAt: '2026-10-04 23:40:36.089573+03', + editedAt: '2026-10-04 23:41:00+03', + comments: [comment], + }; + + const result = normalizePost(source); + + expect(result.createdAt).toBe('2026-10-04T20:40:36.089Z'); + expect(result.editedAt).toBe('2026-10-04T20:41:00.000Z'); + expect(result.originalPost?.createdAt).toBe('2026-10-04T19:32:04.381Z'); + expect(result.comments?.[0]?.createdAt).toBe('2026-10-04T19:00:00.500Z'); + expect(result.comments?.[0]?.replies?.[0]?.createdAt).toBe('2026-10-04T19:01:00.000Z'); + expect(source.createdAt).toBe('2026-10-04 23:40:36.089573+03'); + }); + + it('не меняет даты ISO и остальные поля', () => { + const source = post(original('2026-10-04T19:32:04.381Z')); + + expect(normalizePost(source)).toEqual(source); + expect(normalizePost(post(null))).toEqual(post(null)); + }); + + it('не добавляет отсутствующие даты', () => { + const result = normalizePost({ id: 'x' } as Post); + + expect(Object.keys(result)).toEqual(['id']); + }); +}); diff --git a/test/notifications/notifications.test.ts b/test/notifications/notifications.test.ts index f4b6ee0..bb7b3c8 100644 --- a/test/notifications/notifications.test.ts +++ b/test/notifications/notifications.test.ts @@ -432,6 +432,7 @@ describe('ресурс уведомлений', () => { json({ enabled: true, sound: false, + messages: false, follows: true, wallPosts: true, likes: false, @@ -445,6 +446,7 @@ describe('ресурс уведомлений', () => { expect(settings).toEqual({ enabled: true, sound: false, + messages: false, follows: true, wallPosts: true, likes: false, diff --git a/test/resources/status.test.ts b/test/resources/status.test.ts index 4268ed1..44eb5ce 100644 --- a/test/resources/status.test.ts +++ b/test/resources/status.test.ts @@ -63,6 +63,13 @@ describe('utcStampToIso', () => { expect(utcStampToIso('2026-07-23 23:14:25.500')).toBe('2026-07-23T23:14:25.500Z'); }); + it('переводит отметку со смещением пояса в UTC с точностью до миллисекунд', () => { + expect(utcStampToIso('2026-10-04 22:32:04.381097+03')).toBe('2026-10-04T19:32:04.381Z'); + expect(utcStampToIso('2026-10-04 22:32:04.38109+03')).toBe('2026-10-04T19:32:04.381Z'); + expect(utcStampToIso('2026-10-04 22:32:04+05:30')).toBe('2026-10-04T17:02:04.000Z'); + expect(utcStampToIso('2026-10-04 01:00:00.5-02')).toBe('2026-10-04T03:00:00.500Z'); + }); + it('не трогает строки другого вида', () => { expect(utcStampToIso('2026-07-23T23:14:25.370Z')).toBe('2026-07-23T23:14:25.370Z'); expect(utcStampToIso('вчера')).toBe('вчера'); @@ -71,6 +78,9 @@ describe('utcStampToIso', () => { it('не трогает несуществующую дату', () => { expect(utcStampToIso('2026-13-45 99:99:99')).toBe('2026-13-45 99:99:99'); + expect(utcStampToIso('2026-13-45 99:99:99+03')).toBe('2026-13-45 99:99:99+03'); + expect(utcStampToIso('2026-02-30 10:00:00')).toBe('2026-02-30 10:00:00'); + expect(utcStampToIso('2026-02-30 10:00:00+03')).toBe('2026-02-30 10:00:00+03'); }); });