Add channels IT coverage, live UI loading, and runbook

This commit is contained in:
ai5590 2026-03-30 16:06:28 +03:00
parent 9723696b2c
commit c0fba4af94
10 changed files with 549 additions and 69 deletions

View File

@ -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": "<hash-from-GetChannelMessages>"
},
"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. Добавить полноценные интеграционные тесты на негативные кейсы и нагрузку.

View File

@ -1,46 +1,119 @@
import { renderHeader } from '../components/header.js?v=20260330001044'; import { renderHeader } from '../components/header.js?v=20260330001044';
import { channelPosts, channels } from '../mock-data.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 const pageMeta = { id: 'channel-view', title: 'Канал' };
export function render({ navigate, route }) { function findMockChannel(channelId) {
const channelId = route.params.channelId || 'ch1';
const channel = channels.find((c) => c.id === channelId) || channels[0]; const channel = channels.find((c) => c.id === channelId) || channels[0];
const posts = channelPosts[channelId] || []; return {
const isOwnChannel = channel.ownerLogin === '@shine.alex'; channel,
posts: (channelPosts[channel.id] || []).map((post) => ({ title: post.title, body: post.body })),
isOwnChannel: channel.ownerLogin === '@shine.alex',
};
}
const screen = document.createElement('section'); function mapApiMessageToPost(message) {
screen.className = 'stack'; return {
title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`,
screen.append( body: message.text || '(пусто)',
renderHeader({ };
title: `Канал: ${channel.name}`, }
leftAction: { label: '←', onClick: () => navigate('channels-list') },
})
);
function renderBody(screen, navigate, channelData) {
const head = document.createElement('div'); const head = document.createElement('div');
head.className = 'card'; head.className = 'card';
head.innerHTML = ` head.innerHTML = `
<strong># ${channel.name}</strong> <strong># ${channelData.channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p> <p class="meta-muted" style="margin-top:4px;">${channelData.channel.description}</p>
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channel.ownerName}</p> <p class="meta-muted" style="margin-top:8px;">Владелец: ${channelData.channel.ownerName}</p>
`; `;
const actionButton = document.createElement('button'); const actionButton = document.createElement('button');
actionButton.className = isOwnChannel ? 'primary-btn' : 'secondary-btn'; actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn';
actionButton.textContent = isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала'; actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
const feed = document.createElement('div'); const feed = document.createElement('div');
feed.className = 'stack'; feed.className = 'stack';
posts.forEach((post) => { channelData.posts.forEach((post) => {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'card stack'; card.className = 'card stack';
card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`; card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`;
feed.append(card); 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; return screen;
} }

View File

@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js?v=20260330001044'; 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: 'Каналы' }; export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@ -27,6 +28,53 @@ function openSimpleSubscribeModal(kindLabel) {
root.querySelector('#sub-submit').addEventListener('click', close); 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) { function renderChannelRow(channel, navigate) {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item'; row.className = 'list-item';
@ -57,14 +105,66 @@ function renderSection(title, items, navigate) {
header.textContent = title; header.textContent = title;
wrap.append(header); wrap.append(header);
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
items.forEach((channel) => {
wrap.append(renderChannelRow(channel, navigate));
});
return wrap; 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 }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -79,48 +179,6 @@ export function render({ navigate }) {
}) })
); );
const ownChannels = channels loadFeedAndRender(screen, navigate);
.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);
return screen; return screen;
} }

View File

@ -217,6 +217,24 @@ export class AuthService {
if (response.status !== 200) throw opError('CloseActiveSession', response); 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() { close() {
this.ws.close(); this.ws.close();
} }

View File

@ -97,6 +97,8 @@ function createInitialState({ withStoredSession = true } = {}) {
info: '', info: '',
}, },
sessions: [], sessions: [],
channelsFeed: null,
channelsIndex: {},
}; };
} }
@ -232,3 +234,8 @@ export function refreshRegistrationBalance() {
state.registrationPayment.balanceSOL = next; state.registrationPayment.balanceSOL = next;
return next; return next;
} }
export function setChannelsFeed(feed, index) {
state.channelsFeed = feed || null;
state.channelsIndex = index || {};
}

View File

@ -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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController; import shine.db.SqliteDbController;
import utils.blockchain.BlockchainNameUtil;
import java.sql.Connection; import java.sql.Connection;
import java.util.ArrayList; 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(); Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
channel.setOwnerBlockchainName(ownerBch); 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)); channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode); rootRef.setBlockNumber(lineCode);

View File

@ -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);
}
}
}

View File

@ -6,6 +6,7 @@ import test.it.cases.IT_02_Sessions;
import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.IT_03_AddBlock_NoAuth;
import test.it.cases.IT_04_UserParams_NoAuth; import test.it.cases.IT_04_UserParams_NoAuth;
import test.it.cases.IT_05_UserConnections; import test.it.cases.IT_05_UserConnections;
import test.it.cases.IT_06_ChannelsApi;
import test.it.utils.log.TestLog; import test.it.utils.log.TestLog;
import java.util.ArrayList; import java.util.ArrayList;
@ -56,6 +57,9 @@ public class IT_RunAllMain {
String s5 = IT_05_UserConnections.run(); summaries.add(s5); String s5 = IT_05_UserConnections.run(); summaries.add(s5);
if (s5.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } 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); return finish(summaries, failed);
} }

View File

@ -255,6 +255,61 @@ public final class JsonBuilders {
""".formatted(requestId, login); """.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): * Подпись CreateAuthSession(v2):
* preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce * preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce

View File

@ -287,4 +287,32 @@ public final class JsonParsers {
return null; 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;
}
}
} }