Add channels IT coverage, live UI loading, and runbook
This commit is contained in:
parent
9723696b2c
commit
c0fba4af94
140
Dev_Docs/API/07_Channels_Feature_Runbook.md
Normal file
140
Dev_Docs/API/07_Channels_Feature_Runbook.md
Normal 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. Добавить полноценные интеграционные тесты на негативные кейсы и нагрузку.
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 || {};
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
96
src/test/java/test/it/cases/IT_06_ChannelsApi.java
Normal file
96
src/test/java/test/it/cases/IT_06_ChannelsApi.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user