diff --git a/Dev_Docs/API/07_Channels_Feature_Runbook.md b/Dev_Docs/API/07_Channels_Feature_Runbook.md new file mode 100644 index 0000000..a623693 --- /dev/null +++ b/Dev_Docs/API/07_Channels_Feature_Runbook.md @@ -0,0 +1,140 @@ +# 07. Channels Feature Runbook (человеческое описание + диагностика) + +## 1) Что уже сделано простыми словами + +Сейчас реализован полный минимальный контур для каналов: + +1. **Серверные read API**: + - `ListSubscriptionsFeed` — экран списка каналов. + - `GetChannelMessages` — сообщения конкретного канала. + - `GetMessageThread` — дерево обсуждения для сообщения. + +2. **UI вкладки Каналы**: + - при открытии пытается загрузить реальный feed с сервера; + - если сервер недоступен — fallback на мок-данные; + - группы каналов выводятся в нужном порядке; + - есть кнопка «Добавить канал», модалки подписки, переход в канал. + +3. **Проверка уникальности имени канала на сервере** + - в `AddBlock` при `CreateChannelBody` добавлена проверка; + - при дубле возвращается `409 channel_name_already_exists`. + +--- + +## 2) Что тестировать в первую очередь (быстрый чеклист) + +### Базовый smoke +1. Авторизоваться в UI. +2. Открыть вкладку «Каналы». +3. Убедиться, что данные загрузились с сервера (или виден fallback-баннер). +4. Нажать любой канал — должен открыться экран канала с сообщениями. + +### API smoke +1. Вызвать `ListSubscriptionsFeed`. +2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`. +3. Для первого `messages[0]` вызвать `GetMessageThread`. + +### Ошибки +1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`. +2. `GetChannelMessages` с битым channel payload -> `bad_fields`. +3. `GetMessageThread` с несуществующим block -> `message_not_found`. +4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. + +--- + +## 3) Готовые JSON-запросы для ручной диагностики + +## 3.1 ListSubscriptionsFeed +```json +{ + "op": "ListSubscriptionsFeed", + "requestId": "debug-feed-1", + "payload": { + "login": "TestUser1", + "limit": 200 + } +} +``` + +## 3.2 GetChannelMessages +```json +{ + "op": "GetChannelMessages", + "requestId": "debug-ch-1", + "payload": { + "channel": { + "ownerBlockchainName": "TestUser1-001", + "channelRootBlockNumber": 0, + "channelRootBlockHash": "" + }, + "limit": 200, + "sort": "asc" + } +} +``` + +## 3.3 GetMessageThread +```json +{ + "op": "GetMessageThread", + "requestId": "debug-thread-1", + "payload": { + "message": { + "blockchainName": "TestUser1-001", + "blockNumber": 123, + "blockHash": "" + }, + "depthUp": 20, + "depthDown": 2, + "limitChildrenPerNode": 50 + } +} +``` + +--- + +## 4) Что смотреть в ответах + +### ListSubscriptionsFeed +- `payload.login` — канонический login. +- `ownedChannels / followedUsersChannels / followedChannels` — массивы. +- у каждой записи есть: + - `channel.channelRoot.blockNumber`, + - `messagesCount`, + - `lastMessage` (может быть null, если сообщений нет). + +### GetChannelMessages +- `payload.channel` заполнен; +- `payload.messages[]` содержит: + - `likesCount`, `repliesCount`, + - `versionsTotal`, `versions[]`, + - `text` должен быть текущей (последней) версией. + +### GetMessageThread +- `payload.ancestors[]`, `payload.focus`, `payload.descendants[]`. +- у узлов должны быть версии и счетчики. + +--- + +## 5) Частые проблемы и как быстро локализовать + +1. **`status != 200`, code=bad_fields** + - проверить вложенность payload и обязательные поля. + +2. **`message_not_found` в GetMessageThread** + - обычно передали blockNumber/hash не из `messageRef`. + +3. **Пустой список сообщений в GetChannelMessages** + - проверить `ownerBlockchainName` и `channelRootBlockNumber`. + +4. **`channel_name_already_exists` при AddBlock** + - это ожидаемо: в этой цепочке уже есть канал с таким именем. + +--- + +## 6) Для будущей доработки + +1. Добавить курсоры (пагинацию) для больших каналов. +2. Перевести «Подписаться»/«Добавить канал» в UI с демо-заглушек на реальные write RPC. +3. Добавить batch-агрегации для thread/versions (оптимизация). +4. Добавить полноценные интеграционные тесты на негативные кейсы и нагрузку. diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index d7fb07a..ab315e2 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1,46 +1,119 @@ import { renderHeader } from '../components/header.js?v=20260330001044'; import { channelPosts, channels } from '../mock-data.js?v=20260330001044'; +import { authService, state } from '../state.js?v=20260330001044'; export const pageMeta = { id: 'channel-view', title: 'Канал' }; -export function render({ navigate, route }) { - const channelId = route.params.channelId || 'ch1'; +function findMockChannel(channelId) { const channel = channels.find((c) => c.id === channelId) || channels[0]; - const posts = channelPosts[channelId] || []; - const isOwnChannel = channel.ownerLogin === '@shine.alex'; + return { + channel, + posts: (channelPosts[channel.id] || []).map((post) => ({ title: post.title, body: post.body })), + isOwnChannel: channel.ownerLogin === '@shine.alex', + }; +} - const screen = document.createElement('section'); - screen.className = 'stack'; - - screen.append( - renderHeader({ - title: `Канал: ${channel.name}`, - leftAction: { label: '←', onClick: () => navigate('channels-list') }, - }) - ); +function mapApiMessageToPost(message) { + return { + title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`, + body: message.text || '(пусто)', + }; +} +function renderBody(screen, navigate, channelData) { const head = document.createElement('div'); head.className = 'card'; head.innerHTML = ` - # ${channel.name} -

${channel.description}

-

Владелец: ${channel.ownerName}

+ # ${channelData.channel.name} +

${channelData.channel.description}

+

Владелец: ${channelData.channel.ownerName}

`; const actionButton = document.createElement('button'); - actionButton.className = isOwnChannel ? 'primary-btn' : 'secondary-btn'; - actionButton.textContent = isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала'; + actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn'; + actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала'; const feed = document.createElement('div'); feed.className = 'stack'; - posts.forEach((post) => { + channelData.posts.forEach((post) => { const card = document.createElement('article'); card.className = 'card stack'; card.innerHTML = `${post.title}

${post.body}

`; feed.append(card); }); - screen.append(head, actionButton, feed); + const backButton = document.createElement('button'); + backButton.className = 'secondary-btn'; + backButton.textContent = 'Назад к списку'; + backButton.addEventListener('click', () => navigate('channels-list')); + + screen.append(head, actionButton, feed, backButton); +} + +async function loadFromApi(channelId) { + const summary = state.channelsIndex[channelId]; + if (!summary) return null; + + const selector = { + ownerBlockchainName: summary.channel?.ownerBlockchainName, + channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber, + channelRootBlockHash: summary.channel?.channelRoot?.blockHash, + }; + + if (!selector.ownerBlockchainName || selector.channelRootBlockNumber == null) return null; + + const payload = await authService.getChannelMessages(selector, 200, 'asc'); + const posts = (payload.messages || []).map(mapApiMessageToPost); + + return { + channel: { + name: payload.channel?.channelName || summary.channel?.channelName || 'unknown', + description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`, + ownerName: payload.channel?.ownerLogin || summary.channel?.ownerLogin || 'unknown', + }, + posts, + isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(), + }; +} + +export function render({ navigate, route }) { + const channelId = route.params.channelId || 'ch1'; + + const screen = document.createElement('section'); + screen.className = 'stack'; + + const headerTitle = state.channelsIndex[channelId]?.channel?.channelName + ? `Канал: ${state.channelsIndex[channelId].channel.channelName}` + : `Канал: ${(channels.find((c) => c.id === channelId) || channels[0]).name}`; + + screen.append( + renderHeader({ + title: headerTitle, + leftAction: { label: '←', onClick: () => navigate('channels-list') }, + }) + ); + + const loading = document.createElement('div'); + loading.className = 'card meta-muted'; + loading.textContent = 'Загрузка канала...'; + screen.append(loading); + + (async () => { + try { + const apiData = await loadFromApi(channelId); + loading.remove(); + if (apiData) { + renderBody(screen, navigate, apiData); + return; + } + } catch { + // fallback to mock below + } + + loading.remove(); + renderBody(screen, navigate, findMockChannel(channelId)); + })(); + return screen; } diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 8d335c1..ed39609 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -1,5 +1,6 @@ import { renderHeader } from '../components/header.js?v=20260330001044'; -import { channels } from '../mock-data.js?v=20260330001044'; +import { channels as mockChannels } from '../mock-data.js?v=20260330001044'; +import { authService, setChannelsFeed, state } from '../state.js?v=20260330001044'; export const pageMeta = { id: 'channels-list', title: 'Каналы' }; @@ -27,6 +28,53 @@ function openSimpleSubscribeModal(kindLabel) { root.querySelector('#sub-submit').addEventListener('click', close); } +function initialsFromName(name = '') { + const parts = name.split(/\s+/).filter(Boolean); + return (parts[0]?.[0] || '#') + (parts[1]?.[0] || ''); +} + +function mapMockGroups() { + const ownChannels = mockChannels.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own'); + const followedUserChannels = mockChannels.filter((channel) => channel.kind === 'followed-user-channel'); + const subscribedChannels = mockChannels.filter((channel) => channel.kind === 'subscribed'); + return { ownChannels, followedUserChannels, subscribedChannels, index: {} }; +} + +function mapApiChannelRow(summary, bucketKey, idx, index) { + const rowId = `${bucketKey}-${idx}`; + index[rowId] = summary; + + return { + id: rowId, + source: 'api', + ownerName: summary.channel?.ownerLogin || 'unknown', + initials: initialsFromName(summary.channel?.channelName || summary.channel?.ownerLogin || '?'), + name: summary.channel?.channelName || '(без имени)', + description: `owner=${summary.channel?.ownerLogin || '-'} / bch=${summary.channel?.ownerBlockchainName || '-'}`, + lastMessage: summary.lastMessage?.text || 'Сообщений пока нет', + time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '—', + messagesCount: summary.messagesCount || 0, + }; +} + +function mapApiFeed(feed) { + const index = {}; + + const ownChannels = (feed?.ownedChannels || []).map((it, idx) => mapApiChannelRow(it, 'own', idx, index)); + const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index)); + const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index)); + + ownChannels.sort((a, b) => { + const ap = index[a.id]?.channel?.personal === true; + const bp = index[b.id]?.channel?.personal === true; + if (ap && !bp) return -1; + if (!ap && bp) return 1; + return a.name.localeCompare(b.name, 'ru'); + }); + + return { ownChannels, followedUserChannels, subscribedChannels, index }; +} + function renderChannelRow(channel, navigate) { const row = document.createElement('article'); row.className = 'list-item'; @@ -57,14 +105,66 @@ function renderSection(title, items, navigate) { header.textContent = title; wrap.append(header); - - items.forEach((channel) => { - wrap.append(renderChannelRow(channel, navigate)); - }); + items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate))); return wrap; } +function renderGroupedList(screen, navigate, groups) { + const listWrap = document.createElement('div'); + listWrap.className = 'channels-scroll-wrap'; + + const list = document.createElement('div'); + list.className = 'stack channels-groups'; + + list.append(renderSection('Мои каналы', groups.ownChannels, navigate)); + + const dividerOne = document.createElement('hr'); + dividerOne.className = 'channels-divider'; + list.append(dividerOne); + + list.append(renderSection('Каналы пользователей, на кого вы подписаны', groups.followedUserChannels, navigate)); + + const dividerTwo = document.createElement('hr'); + dividerTwo.className = 'channels-divider'; + list.append(dividerTwo); + + list.append(renderSection('Каналы, на которые вы подписаны', groups.subscribedChannels, navigate)); + + const addChannelButton = document.createElement('button'); + addChannelButton.className = 'primary-btn'; + addChannelButton.textContent = 'Добавить канал'; + addChannelButton.addEventListener('click', () => navigate('add-channel-view')); + + list.append(addChannelButton); + + const scrollHint = document.createElement('div'); + scrollHint.className = 'channels-scroll-hint'; + + listWrap.append(list, scrollHint); + screen.append(listWrap); +} + +async function loadFeedAndRender(screen, navigate) { + const status = document.createElement('div'); + status.className = 'card meta-muted'; + status.textContent = 'Загрузка каналов с сервера...'; + screen.append(status); + + try { + if (!state.session.login) throw new Error('not_authorized'); + const feed = await authService.listSubscriptionsFeed(state.session.login, 200); + const groups = mapApiFeed(feed); + setChannelsFeed(feed, groups.index); + status.remove(); + renderGroupedList(screen, navigate, groups); + } catch { + setChannelsFeed(null, {}); + status.textContent = 'Сервер недоступен или нет данных. Показаны демо-каналы.'; + renderGroupedList(screen, navigate, mapMockGroups()); + } +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -79,48 +179,6 @@ export function render({ navigate }) { }) ); - const ownChannels = channels - .filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own') - .sort((a, b) => { - if (a.kind === 'own-personal') return -1; - if (b.kind === 'own-personal') return 1; - return a.name.localeCompare(b.name, 'ru'); - }); - - const followedUserChannels = channels.filter((channel) => channel.kind === 'followed-user-channel'); - const subscribedChannels = channels.filter((channel) => channel.kind === 'subscribed'); - - const listWrap = document.createElement('div'); - listWrap.className = 'channels-scroll-wrap'; - - const list = document.createElement('div'); - list.className = 'stack channels-groups'; - - list.append(renderSection('Мои каналы', ownChannels, navigate)); - - const dividerOne = document.createElement('hr'); - dividerOne.className = 'channels-divider'; - list.append(dividerOne); - - list.append(renderSection('Каналы пользователей, на кого вы подписаны', followedUserChannels, navigate)); - - const dividerTwo = document.createElement('hr'); - dividerTwo.className = 'channels-divider'; - list.append(dividerTwo); - - list.append(renderSection('Каналы, на которые вы подписаны', subscribedChannels, navigate)); - - const addChannelButton = document.createElement('button'); - addChannelButton.className = 'primary-btn'; - addChannelButton.textContent = 'Добавить канал'; - addChannelButton.addEventListener('click', () => navigate('add-channel-view')); - - list.append(addChannelButton); - - const scrollHint = document.createElement('div'); - scrollHint.className = 'channels-scroll-hint'; - - listWrap.append(list, scrollHint); - screen.append(listWrap); + loadFeedAndRender(screen, navigate); return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 93d477f..8b0f8c3 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -217,6 +217,24 @@ export class AuthService { if (response.status !== 200) throw opError('CloseActiveSession', response); } + async listSubscriptionsFeed(login, limit = 200) { + const response = await this.ws.request('ListSubscriptionsFeed', { login, limit }); + if (response.status !== 200) throw opError('ListSubscriptionsFeed', response); + return response.payload || {}; + } + + async getChannelMessages(channel, limit = 200, sort = 'asc') { + const response = await this.ws.request('GetChannelMessages', { channel, limit, sort }); + if (response.status !== 200) throw opError('GetChannelMessages', response); + return response.payload || {}; + } + + async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) { + const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode }); + if (response.status !== 200) throw opError('GetMessageThread', response); + return response.payload || {}; + } + close() { this.ws.close(); } diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 46dd425..268e6c6 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -97,6 +97,8 @@ function createInitialState({ withStoredSession = true } = {}) { info: '', }, sessions: [], + channelsFeed: null, + channelsIndex: {}, }; } @@ -232,3 +234,8 @@ export function refreshRegistrationBalance() { state.registrationPayment.balanceSOL = next; return next; } + +export function setChannelsFeed(feed, index) { + state.channelsFeed = feed || null; + state.channelsIndex = index || {}; +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 32aeace..9005768 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -11,6 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMe import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.SqliteDbController; +import utils.blockchain.BlockchainNameUtil; import java.sql.Connection; import java.util.ArrayList; @@ -47,7 +48,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel(); channel.setOwnerBlockchainName(ownerBch); - channel.setOwnerLogin(ownerBch.contains("-") ? ownerBch.substring(0, ownerBch.indexOf('-')) : ownerBch); + channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch)); channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode)); Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); rootRef.setBlockNumber(lineCode); diff --git a/src/test/java/test/it/cases/IT_06_ChannelsApi.java b/src/test/java/test/it/cases/IT_06_ChannelsApi.java new file mode 100644 index 0000000..8005566 --- /dev/null +++ b/src/test/java/test/it/cases/IT_06_ChannelsApi.java @@ -0,0 +1,96 @@ +package test.it.cases; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import test.it.utils.TestConfig; +import test.it.utils.json.JsonBuilders; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * IT_06_ChannelsApi + * + * Проверяет базовые happy-path сценарии для новых операций: + * - ListSubscriptionsFeed + * - GetChannelMessages + * - GetMessageThread + */ +public class IT_06_ChannelsApi { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public static String run() { + TestResult r = new TestResult("IT_06_ChannelsApi"); + Duration t = Duration.ofSeconds(8); + + final String login = TestConfig.LOGIN(); + final String bchName = TestConfig.getBlockchainName(login); + + try (WsSession ws = WsSession.open()) { + String feedResp = ws.call("ListSubscriptionsFeed", JsonBuilders.listSubscriptionsFeed(login, 200), t); + check200(r, feedResp, "ListSubscriptionsFeed"); + + int ownSize = JsonParsers.payloadArraySize(feedResp, "ownedChannels"); + if (ownSize < 0) { + r.fail("ListSubscriptionsFeed: отсутствует ownedChannels array, resp=" + feedResp); + fail("ownedChannels missing"); + } + r.ok("ListSubscriptionsFeed: ownedChannels size=" + ownSize); + + String chResp = ws.call("GetChannelMessages", JsonBuilders.getChannelMessages(bchName, 0, "", 200, "asc"), t); + check200(r, chResp, "GetChannelMessages"); + + JsonNode chRoot = MAPPER.readTree(chResp); + JsonNode messages = chRoot.path("payload").path("messages"); + if (!messages.isArray()) { + r.fail("GetChannelMessages: payload.messages не массив, resp=" + chResp); + fail("messages is not array"); + } + r.ok("GetChannelMessages: messages size=" + messages.size()); + + if (messages.size() > 0) { + JsonNode first = messages.get(0); + int blockNumber = first.path("messageRef").path("blockNumber").asInt(-1); + String blockHash = first.path("messageRef").path("blockHash").asText(""); + + if (blockNumber > 0 && !blockHash.isBlank()) { + String threadResp = ws.call( + "GetMessageThread", + JsonBuilders.getMessageThread(bchName, blockNumber, blockHash, 20, 2, 50), + t + ); + check200(r, threadResp, "GetMessageThread"); + + JsonNode threadRoot = MAPPER.readTree(threadResp).path("payload"); + if (!threadRoot.path("ancestors").isArray() || !threadRoot.has("focus") || !threadRoot.path("descendants").isArray()) { + r.fail("GetMessageThread: неверная форма payload, resp=" + threadResp); + fail("thread payload shape invalid"); + } + r.ok("GetMessageThread: payload shape OK"); + } else { + r.ok("GetMessageThread: пропущено, у первого сообщения нет корректного ref"); + } + } else { + r.ok("GetMessageThread: пропущено, в канале нет сообщений"); + } + + } catch (Throwable e) { + r.fail("IT_06_ChannelsApi упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + private static void check200(TestResult r, String resp, String op) { + int st = JsonParsers.status(resp); + if (st != 200) { + r.fail(op + ": ожидали status=200, получили " + st + ", resp=" + resp); + fail(op + " status=" + st); + } + } +} diff --git a/src/test/java/test/it/runner/IT_RunAllMain.java b/src/test/java/test/it/runner/IT_RunAllMain.java index 7ae6209..199db7e 100644 --- a/src/test/java/test/it/runner/IT_RunAllMain.java +++ b/src/test/java/test/it/runner/IT_RunAllMain.java @@ -6,6 +6,7 @@ import test.it.cases.IT_02_Sessions; import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.IT_04_UserParams_NoAuth; import test.it.cases.IT_05_UserConnections; +import test.it.cases.IT_06_ChannelsApi; import test.it.utils.log.TestLog; import java.util.ArrayList; @@ -56,6 +57,9 @@ public class IT_RunAllMain { String s5 = IT_05_UserConnections.run(); summaries.add(s5); if (s5.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + String s6 = IT_06_ChannelsApi.run(); summaries.add(s6); + if (s6.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + return finish(summaries, failed); } diff --git a/src/test/java/test/it/utils/json/JsonBuilders.java b/src/test/java/test/it/utils/json/JsonBuilders.java index dd5d1fe..6e88898 100644 --- a/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/src/test/java/test/it/utils/json/JsonBuilders.java @@ -255,6 +255,61 @@ public final class JsonBuilders { """.formatted(requestId, login); } + public static String listSubscriptionsFeed(String login, int limit) { + String requestId = TestIds.next("subsfeed"); + return """ + { + "op": "ListSubscriptionsFeed", + "requestId": "%s", + "payload": { + "login": "%s", + "limit": %d + } + } + """.formatted(requestId, login, limit); + } + + public static String getChannelMessages(String ownerBlockchainName, int channelRootBlockNumber, String channelRootBlockHash, int limit, String sort) { + String requestId = TestIds.next("chmsg"); + String hash = channelRootBlockHash == null ? "" : channelRootBlockHash; + return """ + { + "op": "GetChannelMessages", + "requestId": "%s", + "payload": { + "channel": { + "ownerBlockchainName": "%s", + "channelRootBlockNumber": %d, + "channelRootBlockHash": "%s" + }, + "limit": %d, + "sort": "%s" + } + } + """.formatted(requestId, ownerBlockchainName, channelRootBlockNumber, hash, limit, sort == null ? "asc" : sort); + } + + public static String getMessageThread(String blockchainName, int blockNumber, String blockHash, int depthUp, int depthDown, int limitChildrenPerNode) { + String requestId = TestIds.next("thread"); + String hash = blockHash == null ? "" : blockHash; + return """ + { + "op": "GetMessageThread", + "requestId": "%s", + "payload": { + "message": { + "blockchainName": "%s", + "blockNumber": %d, + "blockHash": "%s" + }, + "depthUp": %d, + "depthDown": %d, + "limitChildrenPerNode": %d + } + } + """.formatted(requestId, blockchainName, blockNumber, hash, depthUp, depthDown, limitChildrenPerNode); + } + /** * Подпись CreateAuthSession(v2): * preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce diff --git a/src/test/java/test/it/utils/json/JsonParsers.java b/src/test/java/test/it/utils/json/JsonParsers.java index ae76e43..7309208 100644 --- a/src/test/java/test/it/utils/json/JsonParsers.java +++ b/src/test/java/test/it/utils/json/JsonParsers.java @@ -287,4 +287,32 @@ public final class JsonParsers { return null; } } + + public static int payloadArraySize(String json, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return -1; + JsonNode arr = payload.get(field); + if (arr == null || !arr.isArray()) return -1; + return arr.size(); + } catch (Exception e) { + return -1; + } + } + + public static int payloadNestedArraySize(String json, String objectField, String arrayField) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return -1; + JsonNode obj = payload.get(objectField); + if (obj == null || !obj.isObject()) return -1; + JsonNode arr = obj.get(arrayField); + if (arr == null || !arr.isArray()) return -1; + return arr.size(); + } catch (Exception e) { + return -1; + } + } }