mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(rrc): improve room message retrieval with pagination support and sequence tracking for messages
This commit is contained in:
parent
e657ead479
commit
5ddca1c4ec
25 changed files with 2621 additions and 79 deletions
|
|
@ -79,7 +79,9 @@ Thumbs.db
|
|||
# Docker files
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
docker-compose.override.yml
|
||||
.dockerignore
|
||||
meshchat-config/
|
||||
|
||||
# Local storage and runtime data
|
||||
storage/
|
||||
|
|
@ -99,11 +101,33 @@ telemetry_test_lxmf/
|
|||
.env.*.local
|
||||
|
||||
.coverage
|
||||
coverage/
|
||||
coverage-electron/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.mutmut-cache
|
||||
mutmut-stats/
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Local benchmark / CI artifact downloads
|
||||
bench_results.json
|
||||
bench_results.txt
|
||||
/cache/
|
||||
.artifacts/
|
||||
|
||||
# Test suites and reports (not used in container image build)
|
||||
tests/
|
||||
|
||||
# Desktop, CI, and packaging trees not used in Docker image
|
||||
electron/
|
||||
.github/
|
||||
packaging/
|
||||
scripts/private/
|
||||
scripts/ci/
|
||||
|
||||
# Host-built micron WASM binaries (Docker/build fetch fresh copies)
|
||||
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm
|
||||
meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
|
||||
|
|
@ -111,11 +135,6 @@ meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
|
|||
.hypothesis
|
||||
.hypothesis/
|
||||
|
||||
# Arch Linux packaging artifacts
|
||||
/packaging/arch/src/
|
||||
/packaging/arch/pkg/
|
||||
/packaging/arch/*.pkg.tar.zst
|
||||
/packaging/arch/MeshChatX/
|
||||
/packaging/arch/reticulum-meshchatx/
|
||||
cosign.key
|
||||
|
||||
*.exe
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -106,15 +106,29 @@ cosign.key
|
|||
|
||||
# Docker (local compose overrides)
|
||||
docker-compose.override.yml
|
||||
meshchat-config/
|
||||
|
||||
.coverage
|
||||
coverage/
|
||||
coverage-electron/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.mutmut-cache
|
||||
mutmut-stats/
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Local benchmark / CI artifact downloads
|
||||
bench_results.json
|
||||
bench_results.txt
|
||||
/cache/
|
||||
.artifacts/
|
||||
|
||||
# Docker runtime smoke (optional local config dir)
|
||||
meshchatx-docker-smoke-config/
|
||||
|
||||
meshchat-config/
|
||||
|
||||
# micron-parser-go WASM (fetched at build via scripts/fetch-micron-wasm.mjs)
|
||||
|
|
|
|||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -13,18 +13,33 @@ All notable changes to this project will be documented in this file.
|
|||
- **Archives / RNCP / messages**: Rich HTML link clicks use a shared navigation policy so **http(s)** links open externally and mesh links stay in-app (same behavior as Nomad Network and the micron editor preview).
|
||||
- **Android**: External **http(s)** links open in the system browser instead of being silently blocked in the WebView.
|
||||
- **RNSh**: Session startup prefers **`python -m RNS.Utilities.rnsh.rnsh`** over the pip **`rnsh`** console-script wrapper so sessions work when the wrapper is not executable (common with **`pip install --user`**) or when Landlock denies executing paths outside allowed read roots (e.g. **`~/.local/bin/rnsh`**). Fixes [#36](https://github.com/Quad4-Software/MeshChatX/issues/36).
|
||||
- **Interfaces**: The Interfaces page no longer shows all links as **Down** when Reticulum is running normally. **`/api/v1/interface-stats`** now serializes all byte fields (**`network_id`**, interface hashes, IFAC signatures, and related values) instead of failing on unconverted payloads; stats are merged on the frontend by **`interface_name`** as well as **`short_name`**. Fixes false **Down** status when MeshChatX owns its own Reticulum instance (embedded mode); attaching to an already-running **`rnsd`** shared instance was unaffected.
|
||||
- **Interfaces**: The Interfaces page no longer shows all links as **Down** when Reticulum is running normally. **`/api/v1/interface-stats`** now serializes all byte fields (**`network_id`**, interface hashes, IFAC signatures, and related values) instead of failing on unconverted payloads. Stats are merged on the frontend by **`interface_name`** as well as **`short_name`**. Fixes false **Down** status when MeshChatX owns its own Reticulum instance (embedded mode). Attaching to an already-running **`rnsd`** shared instance was unaffected.
|
||||
- **Messages / stranger banner**: The **Add to Contacts** button on the stranger-protection banner now uses the CSRF-aware **`window.api`** client instead of raw **`fetch`**, so adding a contact from the banner works again. It also checks for an existing contact before creating a duplicate, and shows a success or error toast for the result. Fixes [#38](https://github.com/Quad4-Software/MeshChatX/issues/38).
|
||||
- **Android / QR scanning**: Contact, message, and paper-message scanners now use a shared camera helper with higher-resolution constraints, optional continuous autofocus, and a **jsQR** canvas fallback when the WebView **`BarcodeDetector`** API is missing or unreliable. Camera permission is requested at Android startup. Fixes [#41](https://github.com/Quad4-Software/MeshChatX/issues/41).
|
||||
- **macOS**: Universal DMG startup no longer fails with **`ImportError: cannot import name '_add_newdoc_ufunc' from 'numpy._core._multiarray_umath'`** or **`incompatible architecture (have 'arm64', need 'x86_64')`** on NumPy. The x64 cx_Freeze slice now installs locked dependencies via **`uv sync --python-platform x86_64-apple-darwin`** (matching the arm64 slice) instead of unpinned **`pip install -e .`**, and the universal merge step refuses to mix mismatched pure-Python files with native extensions under the same package.
|
||||
- **UI / sidebars**: Collapsed state for the main navigation sidebar and feature sidebars (Messages, Nomad Network, Relay Chat) now persists in localStorage when switching pages or returning later.
|
||||
- **Desktop / crash recovery**: The Electron crash screen now diagnoses database corruption, lists local automatic backups, can restore a backup offline, relaunch with auto-repair or emergency mode, and includes copyable instructions for completely removing MeshChatX and Reticulum data folders.
|
||||
- **Database backups**: Creating a manual backup no longer bundles the just-created backup **`.zip`** into its own archive when the file is written inside the identity storage directory.
|
||||
- **Notifications**: The notification bell now refreshes immediately when a conversation is marked as read from the conversation view or via bulk mark-as-read in Messages, instead of waiting on the next 5-second poll or the next dropdown open.
|
||||
- **Bots**: Error and process log text in the Bots tool no longer picks up a stray line break from the closing **`<pre>`** tag formatting.
|
||||
|
||||
### Added
|
||||
|
||||
- **Messages / images**: Conversation images show a hover download control and support right-click **Save image to device** in the full-screen preview, in addition to the existing message context menu action.
|
||||
- **Docs**: README troubleshooting section for database corruption, backup restore, and full data removal on Linux, macOS, and Windows.
|
||||
|
||||
- **Rich HTML links**: Shared **`NomadRichHtmlLinks`** click handler for **v-html** surfaces (Nomad browser, micron editor preview, archives, RNCP, conversation messages). Routes Nomad mesh and LXMF links in-app, opens **http(s)** externally, scrolls in-page anchors, and blocks unsafe **`javascript:`** navigation. Behavior contract tests guard wiring across frontend, Electron, and Android.
|
||||
- **Location / telemetry**: Settings include a **Disabled** location source so outbound telemetry replies are not sent until the operator opts in. New installs default to **Disabled** instead of browser geolocation.
|
||||
- **Docs**: **`scripts/sync-meshchatx-docs.js`** copies **`docs/*.md`** into the in-app **`meshchatx-docs`** bundle on **`predev`** and **`prebuild-frontend`**. Adds the **Meta Quest (SideQuest)** installation guide.
|
||||
- **Android**: Vendored a patched **bleak** pure-python wheel (unsupported platform-specific backend requirements stripped from its metadata) so **RNode** interfaces can use Bluetooth LE on Android builds.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tests**: Removed obsolete no-op backend tests; strengthened frontend security and UI coverage (**BlockedPage**, **SettingsPage** copy/banished visibility, TGS decode fuzz, **RelayChat** XSS fuzz, **interfaceDiscoveryUtils**, **ArchivesPage**, **NomadRichHtmlLinks**) with behavior assertions instead of always-true checks. Added **`test_interface_stats_endpoint`** for **`/api/v1/interface-stats`** JSON serialization.
|
||||
- **Docs**: README clarifies that Linux AppImage and `.deb` packages require **glibc** (not Alpine/PostmarketOS/musl) and documents **`APPIMAGE_EXTRACT_AND_RUN`** for FUSE-less launches. Fixes [#40](https://github.com/Quad4-Software/MeshChatX/issues/40).
|
||||
- **Tests**: Removed obsolete no-op backend tests. Strengthened frontend security and UI coverage (**BlockedPage**, **SettingsPage** copy/banished visibility, TGS decode fuzz, **RelayChat** XSS fuzz, **interfaceDiscoveryUtils**, **ArchivesPage**, **NomadRichHtmlLinks**) with behavior assertions instead of always-true checks. Added **`test_interface_stats_endpoint`** for **`/api/v1/interface-stats`** JSON serialization, plus an API client usage guard test that fails if a mutating request bypasses the CSRF-aware **`window.api`** client. New **`task test:quick`** / **`test:quick:be`** / **`test:quick:fe`** commands run a fast backend and frontend regression subset, and CI runs a thin **Playwright** smoke suite (`tests/e2e/smoke.spec.js`) against the backend and Vite dev server.
|
||||
- **Packaging**: Arch **`PKGBUILD`** installs Python dependencies with **`uv sync`** instead of a bare **`uv pip install`** step.
|
||||
- **CI**: GitHub Actions workflows were reorganized around shared composite actions (**`fetch-frontend-artifact`**, **`setup-dev-environment`**, **`setup-python-uv`**), the CodeQL workflow was renamed to **`security.yml`**, and Dependabot now also tracks weekly GitHub Actions updates grouped by publisher.
|
||||
- **Dependencies**: Bumped **Vue** to **3.5.39**, **Vuetify** to **3.12.8**, **electron-builder** to **26.15.3**, **Playwright** to **1.61.1**, **prettier** to **3.9.3**, and other minor frontend/backend package versions. Relaxed the **dompurify** version constraint to **`>=3.4.11`** and added an **`app-builder-lib>ajv`** override to keep transitive **ajv** versions compatible.
|
||||
|
||||
## [4.7.1] - 2026-06-21
|
||||
|
||||
|
|
|
|||
|
|
@ -7899,6 +7899,9 @@ class ReticulumMeshChat:
|
|||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# Reticulum Relay Chat
|
||||
RRC_ROOM_MESSAGES_DEFAULT_LIMIT = 200
|
||||
RRC_ROOM_MESSAGES_MAX_LIMIT = 1000
|
||||
|
||||
def _rrc_require_manager():
|
||||
manager = self.rrc_manager
|
||||
if manager is None:
|
||||
|
|
@ -8105,12 +8108,28 @@ class ReticulumMeshChat:
|
|||
return error
|
||||
room = request.match_info.get("room", "")
|
||||
try:
|
||||
messages = hub.room_messages(room)
|
||||
limit = int(request.query.get("limit", RRC_ROOM_MESSAGES_DEFAULT_LIMIT))
|
||||
except (TypeError, ValueError):
|
||||
limit = RRC_ROOM_MESSAGES_DEFAULT_LIMIT
|
||||
limit = max(1, min(limit, RRC_ROOM_MESSAGES_MAX_LIMIT))
|
||||
before_seq_raw = request.query.get("before_seq")
|
||||
before_seq = None
|
||||
if before_seq_raw not in (None, ""):
|
||||
try:
|
||||
before_seq = int(before_seq_raw)
|
||||
except (TypeError, ValueError):
|
||||
before_seq = None
|
||||
try:
|
||||
messages, has_more = hub.room_messages(
|
||||
room, limit=limit, before_seq=before_seq,
|
||||
)
|
||||
members = hub.members_dict(room)
|
||||
except ValueError as e:
|
||||
return web.json_response({"message": str(e)}, status=400)
|
||||
manager.set_active(hub, room)
|
||||
return web.json_response({"messages": messages, "members": members})
|
||||
return web.json_response(
|
||||
{"messages": messages, "members": members, "has_more": has_more},
|
||||
)
|
||||
|
||||
@routes.post("/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages")
|
||||
async def rrc_hub_send_message(request):
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ class RRCHub:
|
|||
self._silent_joins = set()
|
||||
|
||||
self._history_write_failed = False
|
||||
self._seq_counter = 0
|
||||
|
||||
def _next_seq(self):
|
||||
with self._lock:
|
||||
self._seq_counter += 1
|
||||
return self._seq_counter
|
||||
|
||||
def _log(self, msg, level=None):
|
||||
if level is None:
|
||||
|
|
@ -795,6 +801,7 @@ class RRCHub:
|
|||
continue
|
||||
if filter_msgs and m.kind in ("system", "notice"):
|
||||
continue
|
||||
m.seq = self._next_seq()
|
||||
msgs.append(m)
|
||||
with self._lock:
|
||||
self.messages[room] = msgs
|
||||
|
|
@ -824,6 +831,7 @@ class RRCHub:
|
|||
def _record_message(self, msg, local=False):
|
||||
cap = self._per_room_cap()
|
||||
with self._lock:
|
||||
msg.seq = self._next_seq()
|
||||
buf = self.messages.setdefault(msg.room or "*", [])
|
||||
buf.append(msg)
|
||||
if cap is not None and len(buf) > cap:
|
||||
|
|
@ -846,6 +854,7 @@ class RRCHub:
|
|||
msg = proto.RRCMessage("system", room, None, None, text, proto.now_ms())
|
||||
cap = self._per_room_cap()
|
||||
with self._lock:
|
||||
msg.seq = self._next_seq()
|
||||
buf = self.messages.setdefault(room, [])
|
||||
buf.append(msg)
|
||||
if cap is not None and len(buf) > cap:
|
||||
|
|
@ -863,6 +872,7 @@ class RRCHub:
|
|||
|
||||
cap = self._per_room_cap()
|
||||
with self._lock:
|
||||
msg.seq = self._next_seq()
|
||||
self.notices.append(msg)
|
||||
if len(self.notices) > 200:
|
||||
del self.notices[: len(self.notices) - 200]
|
||||
|
|
@ -1342,9 +1352,22 @@ class RRCHub:
|
|||
"max_msg_body_bytes": self.max_msg_body_bytes,
|
||||
}
|
||||
|
||||
def room_messages(self, room):
|
||||
"""Return serialized messages for a room."""
|
||||
return [m.to_dict() for m in self.get_messages(proto.normalize_room(room))]
|
||||
def room_messages(self, room, limit=None, before_seq=None):
|
||||
"""Return ``(messages, has_more)`` for a room, newest page last.
|
||||
|
||||
``before_seq``, when given, restricts results to messages recorded
|
||||
before that sequence number, letting callers page backwards through
|
||||
history. ``limit`` caps how many of the most recent matching messages
|
||||
are returned; ``has_more`` reports whether older messages remain.
|
||||
"""
|
||||
msgs = self.get_messages(proto.normalize_room(room))
|
||||
if before_seq is not None:
|
||||
msgs = [m for m in msgs if (m.seq or 0) < before_seq]
|
||||
has_more = False
|
||||
if limit is not None and len(msgs) > limit:
|
||||
has_more = True
|
||||
msgs = msgs[-limit:]
|
||||
return [m.to_dict() for m in msgs], has_more
|
||||
|
||||
def members_dict(self, room):
|
||||
"""Return serialized members for a room."""
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ class RRCMessage:
|
|||
self.text = text
|
||||
self.ts = ts
|
||||
self.mention = False
|
||||
self.seq = None
|
||||
|
||||
def to_dict(self):
|
||||
"""Return a JSON-serializable representation of the message."""
|
||||
|
|
@ -286,4 +287,5 @@ class RRCMessage:
|
|||
"text": self.text if isinstance(self.text, str) else "",
|
||||
"ts": int(self.ts) if isinstance(self.ts, int) else 0,
|
||||
"mention": bool(self.mention),
|
||||
"seq": self.seq,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,7 +349,9 @@
|
|||
<div class="flex flex-1 min-w-0 flex-col min-h-0">
|
||||
<div
|
||||
ref="messageList"
|
||||
class="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-1.5 sm:p-4"
|
||||
class="relative flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4"
|
||||
style="overflow-anchor: none"
|
||||
@scroll="onMessagesScroll"
|
||||
>
|
||||
<div
|
||||
v-if="!selectedRoom"
|
||||
|
|
@ -358,72 +360,29 @@
|
|||
<MaterialDesignIcon icon-name="chat-outline" class="size-10 opacity-40" />
|
||||
{{ $t("relay_chat.no_room_selected") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
|
||||
<div
|
||||
v-if="entry.type === 'dateDivider'"
|
||||
class="flex items-center justify-center gap-3 w-full my-3 shrink-0 px-2 select-none"
|
||||
role="separator"
|
||||
:aria-label="formatDateDividerLabel(entry.dayKey)"
|
||||
>
|
||||
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
|
||||
<span
|
||||
class="max-w-[min(100%,18rem)] text-center text-[11px] font-medium leading-snug tracking-wide text-sem-fg-muted"
|
||||
>
|
||||
{{ formatDateDividerLabel(entry.dayKey) }}
|
||||
</span>
|
||||
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
|
||||
</div>
|
||||
<template v-else-if="entry.msg">
|
||||
<div
|
||||
v-if="
|
||||
entry.msg.kind === 'system' ||
|
||||
entry.msg.kind === 'notice' ||
|
||||
entry.msg.kind === 'error'
|
||||
"
|
||||
class="py-0.5 text-center text-xs italic"
|
||||
:class="
|
||||
entry.msg.kind === 'error' ? 'text-sem-danger' : 'text-sem-fg-muted'
|
||||
"
|
||||
:data-msg-key="messageKey(entry.msg)"
|
||||
>
|
||||
{{ entry.msg.text }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="entry.msg.kind === 'action'"
|
||||
class="rounded-lg px-2 py-1 text-sm italic"
|
||||
:class="entry.msg.mention ? 'bg-sem-warning/15' : ''"
|
||||
:data-msg-key="messageKey(entry.msg)"
|
||||
>
|
||||
<span class="mr-1 text-xs text-sem-fg-muted">{{
|
||||
formatTime(entry.msg.ts)
|
||||
}}</span>
|
||||
* {{ displayName(entry.msg) }} {{ entry.msg.text }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg px-2 py-1 text-sm"
|
||||
:class="
|
||||
entry.msg.mention
|
||||
? 'bg-sem-warning/15'
|
||||
: 'hover:bg-sem-surface/40 dark:hover:bg-sem-surface/20'
|
||||
"
|
||||
:data-msg-key="messageKey(entry.msg)"
|
||||
@contextmenu.prevent="openMessageContextMenu($event, entry.msg)"
|
||||
>
|
||||
<span class="mr-1.5 text-xs text-sem-fg-muted">{{
|
||||
formatTime(entry.msg.ts)
|
||||
}}</span>
|
||||
<span class="mr-1.5 font-semibold" :style="nameStyle(entry.msg)"
|
||||
>{{ displayName(entry.msg) }}:</span
|
||||
>
|
||||
<span class="whitespace-pre-wrap break-words">{{
|
||||
entry.msg.text
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-else class="relative min-w-0" :class="useVirtualMessageList ? '' : 'space-y-1.5'">
|
||||
<button
|
||||
v-show="!isLoadingPrevious && hasMorePrevious"
|
||||
type="button"
|
||||
class="absolute top-0 left-1/2 z-20 -translate-x-1/2 flex items-center gap-1.5 rounded-full border border-sem-border bg-sem-canvas/95 px-3 py-1 text-xs font-medium text-sem-fg-secondary shadow-xs backdrop-blur-sm hover:bg-sem-surface/60"
|
||||
@click="loadPreviousMessages"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="arrow-up" class="size-3.5" />
|
||||
{{ $t("relay_chat.load_previous") }}
|
||||
</button>
|
||||
<template v-if="!useVirtualMessageList">
|
||||
<template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
|
||||
<RelayMessageEntry :entry="entry" :page="relayChatPageSelf" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<RelayMessageListVirtual
|
||||
v-else
|
||||
ref="messageListVirtual"
|
||||
:entries="messageTimeline"
|
||||
:get-scroll-element="getMessagesScrollElement"
|
||||
:page="relayChatPageSelf"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
|
|
@ -1103,12 +1062,15 @@ import { DEFAULT_RRC_HUB_ICON, normalizeMdiIconName } from "../../js/mdiIconName
|
|||
import { countRelayMentions } from "../../js/relayMentionCount.js";
|
||||
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
|
||||
import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
|
||||
import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
|
||||
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
|
||||
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
|
||||
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
|
||||
import RelayHostModerationPage from "./RelayHostModerationPage.vue";
|
||||
import RelayMessageEntry from "./RelayMessageEntry.vue";
|
||||
import RelayMessageListVirtual from "./RelayMessageListVirtual.vue";
|
||||
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
|
||||
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
|
||||
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
|
||||
|
|
@ -1128,6 +1090,9 @@ const BTN_DANGER_SM =
|
|||
"inline-flex items-center justify-center rounded-lg border border-sem-border bg-sem-canvas p-1.5 text-sem-fg transition hover:border-sem-danger hover:text-sem-danger hover:bg-sem-danger/10";
|
||||
|
||||
const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
|
||||
const RELAY_MESSAGES_INITIAL_PAGE_SIZE = 150;
|
||||
const RELAY_MESSAGES_PREVIOUS_PAGE_SIZE = 100;
|
||||
const LOAD_PREVIOUS_SCROLL_EDGE_PX = 200;
|
||||
|
||||
export default {
|
||||
name: "RelayChatPage",
|
||||
|
|
@ -1135,6 +1100,8 @@ export default {
|
|||
MaterialDesignIcon,
|
||||
MdiIconPickerModal,
|
||||
RelayHostModerationPage,
|
||||
RelayMessageEntry,
|
||||
RelayMessageListVirtual,
|
||||
ContextMenuPanel,
|
||||
ContextMenuItem,
|
||||
ContextMenuDivider,
|
||||
|
|
@ -1205,6 +1172,10 @@ export default {
|
|||
},
|
||||
messages: [],
|
||||
members: [],
|
||||
hasMorePrevious: false,
|
||||
isLoadingPrevious: false,
|
||||
loadPreviousInFlight: 0,
|
||||
roomSelectSequence: 0,
|
||||
composer: "",
|
||||
sending: false,
|
||||
joinRoomName: "",
|
||||
|
|
@ -1269,6 +1240,27 @@ export default {
|
|||
messageTimeline() {
|
||||
return buildRelayMessageTimeline(this.messages);
|
||||
},
|
||||
relayChatPageSelf() {
|
||||
return this;
|
||||
},
|
||||
useVirtualMessageList() {
|
||||
if (this.messageTimeline.length < MIN_VIRTUAL_RELAY_ENTRIES) {
|
||||
return false;
|
||||
}
|
||||
return GlobalState?.config?.message_list_virtualization !== false;
|
||||
},
|
||||
oldestLoadedSeq() {
|
||||
let minSeq = null;
|
||||
for (const msg of this.messages) {
|
||||
if (typeof msg.seq !== "number") {
|
||||
continue;
|
||||
}
|
||||
if (minSeq === null || msg.seq < minSeq) {
|
||||
minSeq = msg.seq;
|
||||
}
|
||||
}
|
||||
return minSeq;
|
||||
},
|
||||
searchResults() {
|
||||
return filterRelayMessages(this.messages, this.messageSearch, (msg) => this.displayName(msg));
|
||||
},
|
||||
|
|
@ -1829,6 +1821,10 @@ export default {
|
|||
}
|
||||
this.showSearch = false;
|
||||
const key = this.messageKey(msg);
|
||||
if (this.useVirtualMessageList) {
|
||||
nextTick(() => this.$refs.messageListVirtual?.scrollToMessageKey(key));
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = this.$refs.messageList?.querySelector(`[data-msg-key="${key}"]`);
|
||||
el?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
|
|
@ -1898,22 +1894,89 @@ export default {
|
|||
this.selectedHubHash = hubHash;
|
||||
this.selectedRoom = room;
|
||||
this.expandedHubs[hubHash] = true;
|
||||
this.hasMorePrevious = false;
|
||||
const seq = ++this.roomSelectSequence;
|
||||
try {
|
||||
const response = await window.api.get(
|
||||
`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`
|
||||
`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`,
|
||||
{ params: { limit: RELAY_MESSAGES_INITIAL_PAGE_SIZE } }
|
||||
);
|
||||
if (seq !== this.roomSelectSequence) {
|
||||
return;
|
||||
}
|
||||
this.messages = response.data?.messages || [];
|
||||
this.members = response.data?.members || [];
|
||||
this.hasMorePrevious = Boolean(response.data?.has_more);
|
||||
this.scrollToBottom();
|
||||
if (!options.restore) {
|
||||
await this.fetchHubs();
|
||||
}
|
||||
this.persistRelayLayout();
|
||||
} catch {
|
||||
if (seq !== this.roomSelectSequence) {
|
||||
return;
|
||||
}
|
||||
this.messages = [];
|
||||
this.members = [];
|
||||
this.hasMorePrevious = false;
|
||||
}
|
||||
},
|
||||
async loadPreviousMessages() {
|
||||
if (this.isLoadingPrevious || !this.hasMorePrevious || !this.selectedHubHash || !this.selectedRoom) {
|
||||
return;
|
||||
}
|
||||
const beforeSeq = this.oldestLoadedSeq;
|
||||
if (beforeSeq === null) {
|
||||
this.hasMorePrevious = false;
|
||||
return;
|
||||
}
|
||||
const seq = this.roomSelectSequence;
|
||||
const hubHash = this.selectedHubHash;
|
||||
const room = this.selectedRoom;
|
||||
this.loadPreviousInFlight += 1;
|
||||
this.isLoadingPrevious = true;
|
||||
try {
|
||||
const response = await window.api.get(
|
||||
`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`,
|
||||
{ params: { limit: RELAY_MESSAGES_PREVIOUS_PAGE_SIZE, before_seq: beforeSeq } }
|
||||
);
|
||||
if (seq !== this.roomSelectSequence || hubHash !== this.selectedHubHash || room !== this.selectedRoom) {
|
||||
return;
|
||||
}
|
||||
const older = response.data?.messages || [];
|
||||
this.hasMorePrevious = Boolean(response.data?.has_more);
|
||||
if (older.length === 0) {
|
||||
return;
|
||||
}
|
||||
const scrollEl = this.useVirtualMessageList ? null : this.$refs.messageList;
|
||||
const prevScrollHeight = scrollEl ? scrollEl.scrollHeight : 0;
|
||||
const prevScrollTop = scrollEl ? scrollEl.scrollTop : 0;
|
||||
this.messages = [...older, ...this.messages];
|
||||
if (scrollEl) {
|
||||
nextTick(() => {
|
||||
const delta = scrollEl.scrollHeight - prevScrollHeight;
|
||||
scrollEl.scrollTop = prevScrollTop + delta;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
this.hasMorePrevious = false;
|
||||
} finally {
|
||||
this.loadPreviousInFlight = Math.max(0, this.loadPreviousInFlight - 1);
|
||||
this.isLoadingPrevious = this.loadPreviousInFlight > 0;
|
||||
}
|
||||
},
|
||||
onMessagesScroll(event) {
|
||||
const el = event.target;
|
||||
if (!el || this.isLoadingPrevious || !this.hasMorePrevious) {
|
||||
return;
|
||||
}
|
||||
if (el.scrollTop <= LOAD_PREVIOUS_SCROLL_EDGE_PX) {
|
||||
this.loadPreviousMessages();
|
||||
}
|
||||
},
|
||||
getMessagesScrollElement() {
|
||||
return this.$refs.messageList ?? null;
|
||||
},
|
||||
async refreshMembers() {
|
||||
if (!this.selectedHubHash || !this.selectedRoom) {
|
||||
return;
|
||||
|
|
@ -1929,6 +1992,10 @@ export default {
|
|||
},
|
||||
scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (this.useVirtualMessageList && this.$refs.messageListVirtual) {
|
||||
this.$refs.messageListVirtual.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
const el = this.$refs.messageList;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="entry.type === 'dateDivider'"
|
||||
class="flex items-center justify-center gap-3 w-full my-3 shrink-0 px-2 select-none"
|
||||
role="separator"
|
||||
:aria-label="page.formatDateDividerLabel(entry.dayKey)"
|
||||
>
|
||||
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
|
||||
<span
|
||||
class="max-w-[min(100%,18rem)] text-center text-[11px] font-medium leading-snug tracking-wide text-sem-fg-muted"
|
||||
>
|
||||
{{ page.formatDateDividerLabel(entry.dayKey) }}
|
||||
</span>
|
||||
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isSystemLike"
|
||||
class="py-0.5 text-center text-xs italic"
|
||||
:class="entry.msg.kind === 'error' ? 'text-sem-danger' : 'text-sem-fg-muted'"
|
||||
:data-msg-key="page.messageKey(entry.msg)"
|
||||
>
|
||||
{{ entry.msg.text }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="entry.msg && entry.msg.kind === 'action'"
|
||||
class="rounded-lg px-2 py-1 text-sm italic"
|
||||
:class="entry.msg.mention ? 'bg-sem-warning/15' : ''"
|
||||
:data-msg-key="page.messageKey(entry.msg)"
|
||||
>
|
||||
<span class="mr-1 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
|
||||
* {{ page.displayName(entry.msg) }} {{ entry.msg.text }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="entry.msg"
|
||||
class="rounded-lg px-2 py-1 text-sm"
|
||||
:class="entry.msg.mention ? 'bg-sem-warning/15' : 'hover:bg-sem-surface/40 dark:hover:bg-sem-surface/20'"
|
||||
:data-msg-key="page.messageKey(entry.msg)"
|
||||
@contextmenu.prevent="page.openMessageContextMenu($event, entry.msg)"
|
||||
>
|
||||
<span class="mr-1.5 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
|
||||
<span class="mr-1.5 font-semibold" :style="page.nameStyle(entry.msg)">{{ page.displayName(entry.msg) }}:</span>
|
||||
<span class="whitespace-pre-wrap break-words">{{ entry.msg.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
entry: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
page: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isSystemLike = computed(() => {
|
||||
const kind = props.entry?.msg?.kind;
|
||||
return kind === "system" || kind === "notice" || kind === "error";
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div class="relative w-full shrink-0" :style="{ height: totalSize + 'px' }">
|
||||
<div
|
||||
v-for="v in virtualItems"
|
||||
:key="entryKey(entries[v.index], v.index)"
|
||||
:ref="measureElement"
|
||||
:data-index="v.index"
|
||||
class="absolute left-0 top-0 w-full box-border px-0 [overflow-anchor:none]"
|
||||
:style="{ transform: `translateY(${v.start}px)` }"
|
||||
>
|
||||
<RelayMessageEntry :entry="entries[v.index]" :page="page" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useVirtualizer } from "@tanstack/vue-virtual";
|
||||
import RelayMessageEntry from "./RelayMessageEntry.vue";
|
||||
import { estimateRelayEntryHeight, findRelayEntryIndexForMessageKey } from "./relayMessageListVirtual.js";
|
||||
|
||||
const props = defineProps({
|
||||
entries: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
getScrollElement: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
page: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const virtualizer = useVirtualizer(
|
||||
computed(() => ({
|
||||
count: props.entries.length,
|
||||
getScrollElement: () => props.getScrollElement() ?? null,
|
||||
estimateSize: (index) => estimateRelayEntryHeight(props.entries[index]),
|
||||
overscan: 12,
|
||||
}))
|
||||
);
|
||||
|
||||
const virtualItems = computed(() => virtualizer.value.getVirtualItems());
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize());
|
||||
|
||||
function entryKey(entry, index) {
|
||||
if (!entry) {
|
||||
return index;
|
||||
}
|
||||
if (entry.type === "dateDivider") {
|
||||
return `date-${entry.dayKey}`;
|
||||
}
|
||||
return props.page.messageKey(entry.msg);
|
||||
}
|
||||
|
||||
function measureElement(el) {
|
||||
if (el) {
|
||||
virtualizer.value.measureElement(el);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToMessageKey(key) {
|
||||
const idx = findRelayEntryIndexForMessageKey(props.entries, key, props.page.messageKey);
|
||||
if (idx < 0) {
|
||||
return;
|
||||
}
|
||||
virtualizer.value.scrollToIndex(idx, { align: "center", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const n = props.entries.length;
|
||||
if (n === 0) {
|
||||
return;
|
||||
}
|
||||
virtualizer.value.scrollToIndex(n - 1, { align: "end", behavior: "auto" });
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollToMessageKey,
|
||||
scrollToBottom,
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
export const MIN_VIRTUAL_RELAY_ENTRIES = 150;
|
||||
|
||||
/**
|
||||
* Initial row height guess before measureElement runs (variable-height rows).
|
||||
* @param {unknown} entry
|
||||
* @returns {number}
|
||||
*/
|
||||
export function estimateRelayEntryHeight(entry) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return 28;
|
||||
}
|
||||
if (entry.type === "dateDivider") {
|
||||
return 44;
|
||||
}
|
||||
const text = typeof entry.msg?.text === "string" ? entry.msg.text : "";
|
||||
let height = 28;
|
||||
if (text) {
|
||||
height += Math.ceil(text.length / 80) * 18;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[]} entries
|
||||
* @param {string} key
|
||||
* @param {(msg: object) => string} keyFn
|
||||
* @returns {number}
|
||||
*/
|
||||
export function findRelayEntryIndexForMessageKey(entries, key, keyFn) {
|
||||
if (!entries?.length || !key || typeof keyFn !== "function") {
|
||||
return -1;
|
||||
}
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry?.type === "message" && entry.msg && keyFn(entry.msg) === key) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -2264,6 +2264,7 @@
|
|||
"no_hubs": "No hubs configured yet. Add one to get started.",
|
||||
"no_rooms": "No rooms joined. Join a room to start chatting.",
|
||||
"no_room_selected": "Select a room to view messages.",
|
||||
"load_previous": "Load earlier messages",
|
||||
"rooms": "Rooms",
|
||||
"members": "Members",
|
||||
"join_room": "Join Room",
|
||||
|
|
|
|||
65
scripts/ci/docker-runtime-smoke.sh
Executable file
65
scripts/ci/docker-runtime-smoke.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the standard Docker image and verify /api/v1/status over HTTPS inside the container.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
IMAGE="${MESHCHAT_DOCKER_SMOKE_IMAGE:-meshchatx-runtime-smoke:local}"
|
||||
CONTAINER="${MESHCHAT_DOCKER_SMOKE_CONTAINER:-meshchatx-runtime-smoke}"
|
||||
HOST_PORT="${MESHCHAT_DOCKER_SMOKE_PORT:-18080}"
|
||||
CONFIG_DIR="${MESHCHAT_DOCKER_SMOKE_CONFIG:-$(mktemp -d)}"
|
||||
TIMEOUT_SEC="${MESHCHAT_DOCKER_SMOKE_TIMEOUT:-180}"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
if [ "${MESHCHAT_DOCKER_SMOKE_KEEP_CONFIG:-0}" != "1" ] && [ -n "${TMP_CONFIG_CREATED:-}" ]; then
|
||||
rm -rf "$CONFIG_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "$CONFIG_DIR" = "$(mktemp -u)" ] || [ ! -d "$CONFIG_DIR" ]; then
|
||||
:
|
||||
fi
|
||||
if [ -z "${MESHCHAT_DOCKER_SMOKE_CONFIG:-}" ]; then
|
||||
TMP_CONFIG_CREATED=1
|
||||
fi
|
||||
|
||||
echo "Building Docker image ${IMAGE}..."
|
||||
docker build -f Dockerfile -t "$IMAGE" .
|
||||
|
||||
cleanup
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
echo "Starting container on host port ${HOST_PORT}..."
|
||||
docker run -d \
|
||||
--name "$CONTAINER" \
|
||||
-p "${HOST_PORT}:8000" \
|
||||
-v "${CONFIG_DIR}:/config" \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
deadline=$((SECONDS + TIMEOUT_SEC))
|
||||
status_ok=0
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
if curl -fsSk "https://127.0.0.1:${HOST_PORT}/api/v1/status" >/tmp/meshchatx-docker-status.json 2>/dev/null; then
|
||||
if python3 -c 'import json,sys; d=json.load(open("/tmp/meshchatx-docker-status.json")); sys.exit(0 if d.get("status")=="ok" else 1)'; then
|
||||
status_ok=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
echo "Container exited early. Logs:" >&2
|
||||
docker logs "$CONTAINER" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$status_ok" != "1" ]; then
|
||||
echo "Timed out waiting for https://127.0.0.1:${HOST_PORT}/api/v1/status" >&2
|
||||
docker logs "$CONTAINER" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Docker runtime smoke passed: $(cat /tmp/meshchatx-docker-status.json)"
|
||||
61
tests/backend/fixtures/ws_message_manifest.json
Normal file
61
tests/backend/fixtures/ws_message_manifest.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"client_inbound": [
|
||||
"config.set",
|
||||
"keyboard_shortcuts.delete",
|
||||
"keyboard_shortcuts.get",
|
||||
"keyboard_shortcuts.set",
|
||||
"lxm.generate_paper_uri",
|
||||
"lxm.ingest_uri",
|
||||
"lxmf.forwarding.rule.add",
|
||||
"lxmf.forwarding.rule.delete",
|
||||
"lxmf.forwarding.rule.toggle",
|
||||
"lxmf.forwarding.rules.get",
|
||||
"nomadnet.download.cancel",
|
||||
"nomadnet.file.download",
|
||||
"nomadnet.page.archive.add",
|
||||
"nomadnet.page.archive.flush",
|
||||
"nomadnet.page.archive.load",
|
||||
"nomadnet.page.archives.get",
|
||||
"nomadnet.page.download",
|
||||
"ping"
|
||||
],
|
||||
"client_direct_responses": [
|
||||
"keyboard_shortcuts",
|
||||
"lxm.generate_paper_uri.result",
|
||||
"lxm.ingest_uri.result",
|
||||
"lxmf.forwarding.rules",
|
||||
"nomadnet.download.cancelled",
|
||||
"nomadnet.file.download",
|
||||
"nomadnet.page.archive.added",
|
||||
"nomadnet.page.archives",
|
||||
"nomadnet.page.download",
|
||||
"pong"
|
||||
],
|
||||
"server_broadcast": [
|
||||
"announce",
|
||||
"announced",
|
||||
"blocked_destinations",
|
||||
"config",
|
||||
"identity_switched",
|
||||
"lxmf.delivery",
|
||||
"lxmf.telemetry",
|
||||
"lxmf_message_created",
|
||||
"lxmf_message_deleted",
|
||||
"lxmf_message_state_updated",
|
||||
"new_voicemail",
|
||||
"reticulum_reload_status",
|
||||
"rncp.fetch.completed",
|
||||
"rncp.send.completed",
|
||||
"rncp.transfer.progress",
|
||||
"rnsh.output",
|
||||
"rnsh.session.change",
|
||||
"rrc.change",
|
||||
"rrc.message",
|
||||
"rrc.server.change",
|
||||
"telephone_call_ended",
|
||||
"telephone_call_established",
|
||||
"telephone_initiation_status",
|
||||
"telephone_missed_call",
|
||||
"telephone_ringing"
|
||||
]
|
||||
}
|
||||
110
tests/backend/http_api_contract_runtime.py
Normal file
110
tests/backend/http_api_contract_runtime.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Shared runtime helpers for HTTP JSON response contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import RNS
|
||||
|
||||
from meshchatx.meshchat import ReticulumMeshChat
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpJsonContract:
|
||||
method: str
|
||||
path: str
|
||||
schema: dict
|
||||
match_info: dict[str, str] = field(default_factory=dict)
|
||||
query: dict[str, str] = field(default_factory=dict)
|
||||
allow_statuses: tuple[int, ...] = (200,)
|
||||
alt_schemas: tuple[dict, ...] = ()
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, data: dict[str, str] | None = None):
|
||||
self._data = data or {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
|
||||
def make_contract_app(temp_dir: str, mock_identity: MagicMock) -> ReticulumMeshChat:
|
||||
return ReticulumMeshChat(
|
||||
identity=mock_identity,
|
||||
storage_dir=temp_dir,
|
||||
reticulum_config_dir=temp_dir,
|
||||
)
|
||||
|
||||
|
||||
def make_minimal_identity() -> MagicMock:
|
||||
mock_id = MagicMock(spec=RNS.Identity)
|
||||
mock_id.hash = b"test_hash_32_bytes_long_01234567"
|
||||
mock_id.hexhash = mock_id.hash.hex()
|
||||
key = b"public_key_bytes_32_chars_long!!"
|
||||
mock_id.get_private_key.return_value = key
|
||||
mock_id.get_public_key.return_value = key
|
||||
return mock_id
|
||||
|
||||
|
||||
def bootstrap_contract_app(app: ReticulumMeshChat) -> ReticulumMeshChat:
|
||||
ctx = app.current_context
|
||||
if ctx is None:
|
||||
return app
|
||||
lxmf_dest = MagicMock()
|
||||
lxmf_dest.hexhash = "cc" * 16
|
||||
ctx.local_lxmf_destination = lxmf_dest
|
||||
if getattr(app, "message_router", None) is not None:
|
||||
prop = MagicMock()
|
||||
prop.hexhash = "dd" * 16
|
||||
app.message_router.propagation_destination = prop
|
||||
if getattr(ctx, "telephone_manager", None) is not None:
|
||||
telephone = getattr(ctx.telephone_manager, "telephone", None)
|
||||
if telephone is not None:
|
||||
dest = MagicMock()
|
||||
dest.hexhash = "ee" * 16
|
||||
telephone.destination = dest
|
||||
return app
|
||||
|
||||
|
||||
def find_route_handler(app: ReticulumMeshChat, path: str, method: str):
|
||||
for route in app.get_routes():
|
||||
if route.path == path and route.method == method:
|
||||
return route.handler
|
||||
return None
|
||||
|
||||
|
||||
def build_request(
|
||||
*,
|
||||
match_info: dict[str, str] | None = None,
|
||||
query: dict[str, str] | None = None,
|
||||
) -> MagicMock:
|
||||
request = MagicMock()
|
||||
request.match_info = match_info or {}
|
||||
request.query = _Query(query)
|
||||
return request
|
||||
|
||||
|
||||
async def invoke_json_contract(
|
||||
app: ReticulumMeshChat,
|
||||
contract: HttpJsonContract,
|
||||
) -> tuple[int, Any]:
|
||||
handler = find_route_handler(app, contract.path, contract.method)
|
||||
if handler is None:
|
||||
raise AssertionError(
|
||||
f"No handler for {contract.method} {contract.path}",
|
||||
)
|
||||
request = build_request(
|
||||
match_info=contract.match_info,
|
||||
query=contract.query,
|
||||
)
|
||||
if contract.method in {"POST", "PATCH", "PUT", "DELETE"}:
|
||||
request.json = AsyncMock(return_value={})
|
||||
response = await handler(request)
|
||||
status = response.status
|
||||
body = json.loads(response.body)
|
||||
return status, body
|
||||
425
tests/backend/http_api_response_registry.py
Normal file
425
tests/backend/http_api_response_registry.py
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Registry of HTTP JSON response contracts for GET /api/v1 routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from tests.backend.api_json_contract_schemas import (
|
||||
API_V1_APP_INFO_ENVELOPE_SCHEMA,
|
||||
API_V1_STATUS_SCHEMA,
|
||||
AUTH_STATUS_SCHEMA,
|
||||
TELEPHONE_CONTACT_CHECK_SCHEMA,
|
||||
TELEPHONE_CONTACTS_LIST_SCHEMA,
|
||||
TELEPHONE_RINGTONE_STATUS_SCHEMA,
|
||||
TELEPHONE_RINGTONES_LIST_SCHEMA,
|
||||
TELEPHONE_VOICEMAIL_STATUS_SCHEMA,
|
||||
TELEPHONE_VOICEMAILS_ENVELOPE_SCHEMA,
|
||||
)
|
||||
from tests.backend.http_api_contract_runtime import HttpJsonContract
|
||||
from tests.backend.http_api_response_schemas import (
|
||||
ACCESS_ATTEMPTS_SCHEMA,
|
||||
ANNOUNCE_SINGLE_SCHEMA,
|
||||
ANNOUNCES_LIST_SCHEMA,
|
||||
BLACKHOLE_STATUS_SCHEMA,
|
||||
BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA,
|
||||
BOTS_STATUS_SCHEMA,
|
||||
BOTS_SUBPROCESS_LOG_SCHEMA,
|
||||
CHANGELOG_SCHEMA,
|
||||
COMPORTS_ENVELOPE_SCHEMA,
|
||||
COMMUNITY_INTERFACES_SCHEMA,
|
||||
CONFIG_ENVELOPE_SCHEMA,
|
||||
CSRF_ENVELOPE_SCHEMA,
|
||||
DATABASE_BACKUPS_SCHEMA,
|
||||
DATABASE_HEALTH_SCHEMA,
|
||||
DATABASE_SNAPSHOTS_SCHEMA,
|
||||
DEBUG_LOGS_SCHEMA,
|
||||
DESTINATION_DISPLAY_NAME_SCHEMA,
|
||||
DESTINATION_PATH_SCHEMA,
|
||||
DESTINATION_SIGNAL_METRICS_SCHEMA,
|
||||
DESTINATION_STAMP_INFO_SCHEMA,
|
||||
DISCOVERED_INTERFACES_SCHEMA,
|
||||
DISCOVERY_CONFIG_SCHEMA,
|
||||
DOCS_SEARCH_SCHEMA,
|
||||
DOCS_STATUS_SCHEMA,
|
||||
ERROR_ENVELOPE_SCHEMA,
|
||||
FAVOURITES_SCHEMA,
|
||||
GIFS_LIST_SCHEMA,
|
||||
IDENTITIES_LIST_SCHEMA,
|
||||
IDENTITY_BACKUP_BASE32_SCHEMA,
|
||||
INTERFACE_STATS_SCHEMA,
|
||||
INTERFACES_LIST_SCHEMA,
|
||||
LICENSES_ENVELOPE_SCHEMA,
|
||||
LXMF_CONVERSATION_MESSAGES_SCHEMA,
|
||||
LXMF_CONVERSATION_PINS_SCHEMA,
|
||||
LXMF_CONVERSATIONS_SCHEMA,
|
||||
LXMF_FOLDERS_SCHEMA,
|
||||
LXMF_MESSAGE_BLOCKLIST_SCHEMA,
|
||||
LXMF_MESSAGE_URI_SCHEMA,
|
||||
LXMF_PROPAGATION_NODES_SCHEMA,
|
||||
LXMF_PROPAGATION_STATUS_SCHEMA,
|
||||
LXMF_SIEVE_FILTERS_SCHEMA,
|
||||
MAP_DRAWINGS_SCHEMA,
|
||||
MAP_MBTILES_SCHEMA,
|
||||
MAP_OFFLINE_SCHEMA,
|
||||
MEMORY_DIAGNOSTICS_SCHEMA,
|
||||
MESHCHATX_DOCS_CONTENT_SCHEMA,
|
||||
MESHCHATX_DOCS_LIST_SCHEMA,
|
||||
NOMADNET_ARCHIVES_SCHEMA,
|
||||
NOTIFICATIONS_SCHEMA,
|
||||
PAGE_NODE_DETAIL_SCHEMA,
|
||||
PAGE_NODE_FILES_SCHEMA,
|
||||
PAGE_NODE_PAGES_SCHEMA,
|
||||
PAGE_NODES_LIST_SCHEMA,
|
||||
PATH_TABLE_SCHEMA,
|
||||
REPOSITORY_SERVER_LIST_SCHEMA,
|
||||
REPOSITORY_SERVER_STATUS_SCHEMA,
|
||||
RETICULUM_CONFIG_RAW_SCHEMA,
|
||||
RNCP_STATUS_SCHEMA,
|
||||
RNCP_TRANSFER_SCHEMA,
|
||||
RNPATH_RATES_SCHEMA,
|
||||
RNPATH_TABLE_SCHEMA,
|
||||
RNPATH_TRACE_SCHEMA,
|
||||
RNSH_OUTPUT_SCHEMA,
|
||||
RNSH_SESSIONS_SCHEMA,
|
||||
RNSTATUS_SCHEMA,
|
||||
RRC_ACTIVITY_SCHEMA,
|
||||
RRC_HUBS_SCHEMA,
|
||||
RRC_MEMBERS_SCHEMA,
|
||||
RRC_MESSAGES_SCHEMA,
|
||||
RRC_SERVERS_SCHEMA,
|
||||
SERVER_SECURITY_SCHEMA,
|
||||
SPAM_KEYWORDS_SCHEMA,
|
||||
STICKER_PACK_DETAIL_SCHEMA,
|
||||
STICKER_PACKS_LIST_SCHEMA,
|
||||
STICKERS_LIST_SCHEMA,
|
||||
SYSTEM_NETWORK_INTERFACES_SCHEMA,
|
||||
TELEMETRY_HISTORY_SCHEMA,
|
||||
TELEMETRY_LATEST_SCHEMA,
|
||||
TELEMETRY_PEERS_SCHEMA,
|
||||
TELEMETRY_TRACKING_SCHEMA,
|
||||
TELEMETRY_TRUSTED_PEERS_SCHEMA,
|
||||
TELEPHONE_AUDIO_PROFILES_SCHEMA,
|
||||
TELEPHONE_CALL_SCHEMA,
|
||||
TELEPHONE_HISTORY_SCHEMA,
|
||||
TELEPHONE_RECORDINGS_SCHEMA,
|
||||
TELEPHONE_STATUS_SCHEMA,
|
||||
TOOLS_MICRON_PARSER_RELEASE_SCHEMA,
|
||||
TOOLS_RNODE_LATEST_RELEASE_SCHEMA,
|
||||
TRANSLATOR_LANGUAGES_SCHEMA,
|
||||
)
|
||||
|
||||
_HEX32 = "a" * 32
|
||||
_HEX16 = "b" * 16
|
||||
_NODE_ID = "1"
|
||||
_ROOM = "lobby"
|
||||
_HUB_ID = "1"
|
||||
_SESSION_ID = "1"
|
||||
_TRANSFER_ID = "1"
|
||||
_PAGE_NAME = "index.mu"
|
||||
|
||||
HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
|
||||
HttpJsonContract("GET", "/api/v1/status", API_V1_STATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/app/info", API_V1_APP_INFO_ENVELOPE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/app/changelog", CHANGELOG_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/auth/status", AUTH_STATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/auth/csrf", CSRF_ENVELOPE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/server/security", SERVER_SECURITY_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/config", CONFIG_ENVELOPE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/blocked-destinations", BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/comports", COMPORTS_ENVELOPE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/system/network-interfaces", SYSTEM_NETWORK_INTERFACES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/reticulum/interfaces", INTERFACES_LIST_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/community-interfaces", COMMUNITY_INTERFACES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/reticulum/discovery", DISCOVERY_CONFIG_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/reticulum/discovered-interfaces", DISCOVERED_INTERFACES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/reticulum/config/raw", RETICULUM_CONFIG_RAW_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/reticulum/blackhole", BLACKHOLE_STATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/interface-stats", INTERFACE_STATS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/path-table", PATH_TABLE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/licenses", LICENSES_ENVELOPE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/docs/status", DOCS_STATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/docs/search", DOCS_SEARCH_SCHEMA, query={"q": "reticulum"}),
|
||||
HttpJsonContract("GET", "/api/v1/meshchatx-docs/list", MESHCHATX_DOCS_LIST_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/meshchatx-docs/content",
|
||||
MESHCHATX_DOCS_CONTENT_SCHEMA,
|
||||
query={"path": "meshchatx.md"},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/repository-server/status",
|
||||
REPOSITORY_SERVER_STATUS_SCHEMA,
|
||||
allow_statuses=(200, 503),
|
||||
alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/repository-server/list",
|
||||
REPOSITORY_SERVER_LIST_SCHEMA,
|
||||
allow_statuses=(200, 503),
|
||||
alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/database/health", DATABASE_HEALTH_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/database/snapshots", DATABASE_SNAPSHOTS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/database/backups", DATABASE_BACKUPS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/debug/logs", DEBUG_LOGS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/debug/access-attempts", ACCESS_ATTEMPTS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/diagnostics/memory", MEMORY_DIAGNOSTICS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/diagnostics/memory/heap", MEMORY_DIAGNOSTICS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/diagnostics/memory/gc", MEMORY_DIAGNOSTICS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/diagnostics/memory/referrers", MEMORY_DIAGNOSTICS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/identities", IDENTITIES_LIST_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/identity/backup/base32", IDENTITY_BACKUP_BASE32_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/announce", ANNOUNCE_SINGLE_SCHEMA, query={"aspect": "lxmf.delivery"}),
|
||||
HttpJsonContract("GET", "/api/v1/announces", ANNOUNCES_LIST_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/destination/{destination_hash}/path",
|
||||
DESTINATION_PATH_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/destination/{destination_hash}/custom-display-name",
|
||||
DESTINATION_DISPLAY_NAME_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/destination/{destination_hash}/lxmf-stamp-info",
|
||||
DESTINATION_STAMP_INFO_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/destination/{destination_hash}/signal-metrics",
|
||||
DESTINATION_SIGNAL_METRICS_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/notifications", NOTIFICATIONS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/favourites", FAVOURITES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/nomadnet/archives", NOMADNET_ARCHIVES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/page-nodes", PAGE_NODES_LIST_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/page-nodes/{node_id}",
|
||||
PAGE_NODE_DETAIL_SCHEMA,
|
||||
match_info={"node_id": _NODE_ID},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/page-nodes/{node_id}/files",
|
||||
PAGE_NODE_FILES_SCHEMA,
|
||||
match_info={"node_id": _NODE_ID},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/page-nodes/{node_id}/pages",
|
||||
PAGE_NODE_PAGES_SCHEMA,
|
||||
match_info={"node_id": _NODE_ID},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/page-nodes/{node_id}/pages/{page_name}",
|
||||
PAGE_NODE_DETAIL_SCHEMA,
|
||||
match_info={"node_id": _NODE_ID, "page_name": _PAGE_NAME},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/lxmf/conversations", LXMF_CONVERSATIONS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/lxmf/folders", LXMF_FOLDERS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/lxmf/conversation-pins", LXMF_CONVERSATION_PINS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/lxmf/sieve-filters", LXMF_SIEVE_FILTERS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/lxmf/message-blocklist", LXMF_MESSAGE_BLOCKLIST_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/lxmf/propagation-nodes", LXMF_PROPAGATION_NODES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/lxmf/propagation-node/status", LXMF_PROPAGATION_STATUS_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/lxmf-messages/conversation/{destination_hash}",
|
||||
LXMF_CONVERSATION_MESSAGES_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/lxmf-messages/{message_hash}/uri",
|
||||
LXMF_MESSAGE_URI_SCHEMA,
|
||||
match_info={"message_hash": _HEX16},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/gifs", GIFS_LIST_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/stickers", STICKERS_LIST_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/sticker-packs", STICKER_PACKS_LIST_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/sticker-packs/{pack_id}",
|
||||
STICKER_PACK_DETAIL_SCHEMA,
|
||||
match_info={"pack_id": _NODE_ID},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/map/drawings", MAP_DRAWINGS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/map/offline", MAP_OFFLINE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/map/mbtiles", MAP_MBTILES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/telemetry/peers", TELEMETRY_PEERS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/telemetry/tracking", TELEMETRY_TRACKING_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/telemetry/trusted-peers", TELEMETRY_TRUSTED_PEERS_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/telemetry/latest/{destination_hash}",
|
||||
TELEMETRY_LATEST_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/telemetry/history/{destination_hash}",
|
||||
TELEMETRY_HISTORY_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/rrc/hubs", RRC_HUBS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/rrc/servers", RRC_SERVERS_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages",
|
||||
RRC_MESSAGES_SCHEMA,
|
||||
match_info={"hub_hash": _HEX32, "room": _ROOM},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/rrc/servers/{hub_id}/members",
|
||||
RRC_MEMBERS_SCHEMA,
|
||||
match_info={"hub_id": _HUB_ID},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/rrc/servers/{hub_id}/activity",
|
||||
RRC_ACTIVITY_SCHEMA,
|
||||
match_info={"hub_id": _HUB_ID},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/rrc/servers/{hub_id}/messages",
|
||||
RRC_MESSAGES_SCHEMA,
|
||||
match_info={"hub_id": _HUB_ID},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/rncp/status", RNCP_STATUS_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/rncp/transfer/{transfer_id}",
|
||||
RNCP_TRANSFER_SCHEMA,
|
||||
match_info={"transfer_id": _TRANSFER_ID},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/rnpath/trace/{destination_hash}",
|
||||
RNPATH_TRACE_SCHEMA,
|
||||
match_info={"destination_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/rnsh/sessions", RNSH_SESSIONS_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/rnsh/sessions/{session_id}/output",
|
||||
RNSH_OUTPUT_SCHEMA,
|
||||
match_info={"session_id": _SESSION_ID},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/rnstatus", RNSTATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/bots/status", BOTS_STATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/bots/subprocess-log", BOTS_SUBPROCESS_LOG_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/spam-keywords", SPAM_KEYWORDS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/translator/languages", TRANSLATOR_LANGUAGES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/tools/micron-parser-go-release", TOOLS_MICRON_PARSER_RELEASE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/tools/rnode/latest_release", TOOLS_RNODE_LATEST_RELEASE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/telephone/status", TELEPHONE_STATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/telephone/history", TELEPHONE_HISTORY_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/telephone/recordings", TELEPHONE_RECORDINGS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/telephone/audio-profiles", TELEPHONE_AUDIO_PROFILES_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/telephone/call/{identity_hash}",
|
||||
TELEPHONE_CALL_SCHEMA,
|
||||
match_info={"identity_hash": _HEX32},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/telephone/voicemail/status", TELEPHONE_VOICEMAIL_STATUS_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/telephone/voicemails",
|
||||
TELEPHONE_VOICEMAILS_ENVELOPE_SCHEMA,
|
||||
query={"limit": "50", "offset": "0"},
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/telephone/ringtones", TELEPHONE_RINGTONES_LIST_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/telephone/ringtones/status",
|
||||
TELEPHONE_RINGTONE_STATUS_SCHEMA,
|
||||
query={},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/telephone/contacts",
|
||||
TELEPHONE_CONTACTS_LIST_SCHEMA,
|
||||
query={"limit": "100", "offset": "0"},
|
||||
),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"/api/v1/telephone/contacts/check/{identity_hash}",
|
||||
TELEPHONE_CONTACT_CHECK_SCHEMA,
|
||||
match_info={"identity_hash": _HEX16},
|
||||
),
|
||||
)
|
||||
|
||||
HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
|
||||
"/api/v1/database/backup/download",
|
||||
"/api/v1/database/backups/{filename}/download",
|
||||
"/api/v1/database/snapshots/{filename}/download",
|
||||
"/api/v1/docs/export",
|
||||
"/api/v1/docs/export/reticulum",
|
||||
"/api/v1/identity/backup/download",
|
||||
"/api/v1/identities/export-all",
|
||||
"/api/v1/maintenance/messages/export",
|
||||
"/api/v1/bots/export",
|
||||
"/api/v1/gifs/export",
|
||||
"/api/v1/lxmf/folders/export",
|
||||
"/api/v1/lxmf/message-blocklist/export",
|
||||
"/api/v1/stickers/export",
|
||||
"/api/v1/sticker-packs/{pack_id}/export",
|
||||
"/api/v1/telephone/contacts/export",
|
||||
"/api/v1/tools/rnode/download_firmware",
|
||||
"/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}",
|
||||
"/api/v1/gifs/{gif_id}/image",
|
||||
"/api/v1/stickers/{sticker_id}/image",
|
||||
"/api/v1/map/tiles/{z}/{x}/{y}",
|
||||
"/api/v1/map/export/{export_id}",
|
||||
"/api/v1/map/export/{export_id}/download",
|
||||
"/api/v1/telephone/recordings/{id}/audio/{side}",
|
||||
"/api/v1/telephone/ringtones/{id}/audio",
|
||||
"/api/v1/telephone/voicemail/greeting/audio",
|
||||
"/api/v1/telephone/voicemails/{id}/audio",
|
||||
"/api/v1/telephone/answer",
|
||||
"/api/v1/telephone/hangup",
|
||||
"/api/v1/telephone/send-to-voicemail",
|
||||
"/api/v1/telephone/mute-transmit",
|
||||
"/api/v1/telephone/mute-receive",
|
||||
"/api/v1/telephone/unmute-transmit",
|
||||
"/api/v1/telephone/unmute-receive",
|
||||
"/api/v1/telephone/switch-audio-profile/{profile_id}",
|
||||
"/api/v1/lxmf/propagation-node/sync",
|
||||
"/api/v1/lxmf/propagation-node/stop-sync",
|
||||
"/api/v1/ping/{destination_hash}/lxmf.delivery",
|
||||
)
|
||||
|
||||
_HTTP_JSON_GET_EXCLUDED_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"/download$"),
|
||||
re.compile(r"/export$"),
|
||||
re.compile(r"/image$"),
|
||||
re.compile(r"/audio"),
|
||||
re.compile(r"/tiles/"),
|
||||
)
|
||||
|
||||
|
||||
def is_excluded_json_get_route(path: str) -> bool:
|
||||
if path in HTTP_JSON_GET_CONTRACT_EXCLUDED:
|
||||
return True
|
||||
return any(pattern.search(path) for pattern in _HTTP_JSON_GET_EXCLUDED_PATTERNS)
|
||||
|
||||
|
||||
def registered_get_paths() -> set[str]:
|
||||
return {contract.path for contract in HTTP_JSON_GET_CONTRACTS}
|
||||
681
tests/backend/http_api_response_schemas.py
Normal file
681
tests/backend/http_api_response_schemas.py
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Additional JSON Schema definitions for broad HTTP API contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_OBJECT = {"type": "object", "additionalProperties": True}
|
||||
_ARRAY = {"type": "array"}
|
||||
_STRING = {"type": "string"}
|
||||
_INTEGER = {"type": "integer"}
|
||||
_BOOLEAN = {"type": "boolean"}
|
||||
_NUMBER = {"type": "number"}
|
||||
|
||||
ERROR_ENVELOPE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["error"],
|
||||
"properties": {"error": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
MESSAGE_ENVELOPE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"properties": {"message": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
CONFIG_ENVELOPE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["config"],
|
||||
"properties": {"config": _OBJECT},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["blocked_destinations"],
|
||||
"properties": {
|
||||
"blocked_destinations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["destination_hash", "created_at"],
|
||||
"properties": {
|
||||
"destination_hash": _STRING,
|
||||
"created_at": _STRING,
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
SERVER_SECURITY_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"listen_host",
|
||||
"listen_port",
|
||||
"https_enabled",
|
||||
"is_loopback_bind",
|
||||
"web_ui_ip_allowlist",
|
||||
"privacy_mode_enabled",
|
||||
"auth_enabled",
|
||||
],
|
||||
"properties": {
|
||||
"listen_host": {"type": ["string", "null"]},
|
||||
"listen_port": {"type": ["integer", "null"]},
|
||||
"https_enabled": _BOOLEAN,
|
||||
"is_loopback_bind": _BOOLEAN,
|
||||
"web_ui_ip_allowlist": _STRING,
|
||||
"privacy_mode_enabled": _BOOLEAN,
|
||||
"auth_enabled": _BOOLEAN,
|
||||
},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
CSRF_ENVELOPE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["csrf_token"],
|
||||
"properties": {"csrf_token": _STRING},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
COMPORTS_ENVELOPE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["comports"],
|
||||
"properties": {"comports": _ARRAY},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
INTERFACES_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["interfaces"],
|
||||
"properties": {"interfaces": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
COMMUNITY_INTERFACES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["entries"],
|
||||
"properties": {"entries": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
IDENTITIES_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["identities"],
|
||||
"properties": {"identities": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LICENSES_ENVELOPE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["licenses"],
|
||||
"properties": {"licenses": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DOCS_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {"status": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DOCS_SEARCH_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["results"],
|
||||
"properties": {"results": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
MESHCHATX_DOCS_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["docs"],
|
||||
"properties": {"docs": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
MESHCHATX_DOCS_CONTENT_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["content"],
|
||||
"properties": {"content": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DATABASE_HEALTH_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["healthy"],
|
||||
"properties": {"healthy": _BOOLEAN},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DATABASE_SNAPSHOTS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["snapshots"],
|
||||
"properties": {"snapshots": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DATABASE_BACKUPS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["backups"],
|
||||
"properties": {"backups": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DEBUG_LOGS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["logs"],
|
||||
"properties": {"logs": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
ACCESS_ATTEMPTS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["attempts"],
|
||||
"properties": {"attempts": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
MEMORY_DIAGNOSTICS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["rss_bytes"],
|
||||
"properties": {"rss_bytes": _INTEGER},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DISCOVERY_CONFIG_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"discover_interfaces",
|
||||
"interface_discovery_sources",
|
||||
"interface_discovery_whitelist",
|
||||
"interface_discovery_blacklist",
|
||||
"required_discovery_value",
|
||||
],
|
||||
"properties": {
|
||||
"discover_interfaces": {},
|
||||
"interface_discovery_sources": {},
|
||||
"interface_discovery_whitelist": {},
|
||||
"interface_discovery_blacklist": {},
|
||||
"required_discovery_value": {},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DISCOVERED_INTERFACES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["interfaces"],
|
||||
"properties": {"interfaces": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RETICULUM_CONFIG_RAW_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["config"],
|
||||
"properties": {"config": _STRING},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
BLACKHOLE_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"properties": {"enabled": _BOOLEAN},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
INTERFACE_STATS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["interfaces"],
|
||||
"properties": {"interfaces": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
PATH_TABLE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["paths"],
|
||||
"properties": {"paths": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_CONVERSATIONS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["conversations"],
|
||||
"properties": {"conversations": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_FOLDERS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["folders"],
|
||||
"properties": {"folders": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_CONVERSATION_PINS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["pins"],
|
||||
"properties": {"pins": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_SIEVE_FILTERS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["filters"],
|
||||
"properties": {"filters": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_MESSAGE_BLOCKLIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["entries"],
|
||||
"properties": {"entries": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_PROPAGATION_NODES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["nodes"],
|
||||
"properties": {"nodes": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_PROPAGATION_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {"status": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
NOTIFICATIONS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["notifications"],
|
||||
"properties": {"notifications": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
FAVOURITES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["favourites"],
|
||||
"properties": {"favourites": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
PAGE_NODES_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["nodes"],
|
||||
"properties": {"nodes": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
PAGE_NODE_DETAIL_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["node"],
|
||||
"properties": {"node": _OBJECT},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
PAGE_NODE_FILES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["files"],
|
||||
"properties": {"files": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
PAGE_NODE_PAGES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["pages"],
|
||||
"properties": {"pages": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
NOMADNET_ARCHIVES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["archives"],
|
||||
"properties": {"archives": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
GIFS_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["gifs"],
|
||||
"properties": {"gifs": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
STICKERS_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["stickers"],
|
||||
"properties": {"stickers": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
STICKER_PACKS_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["packs"],
|
||||
"properties": {"packs": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
STICKER_PACK_DETAIL_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["pack"],
|
||||
"properties": {"pack": _OBJECT},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
MAP_DRAWINGS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["drawings"],
|
||||
"properties": {"drawings": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
MAP_OFFLINE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["tiles"],
|
||||
"properties": {"tiles": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
MAP_MBTILES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["mbtiles"],
|
||||
"properties": {"mbtiles": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEMETRY_PEERS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["peers"],
|
||||
"properties": {"peers": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEMETRY_TRACKING_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["tracking"],
|
||||
"properties": {"tracking": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEMETRY_TRUSTED_PEERS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["trusted_peers"],
|
||||
"properties": {"trusted_peers": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEMETRY_LATEST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["telemetry"],
|
||||
"properties": {"telemetry": {"type": ["object", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEMETRY_HISTORY_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["history"],
|
||||
"properties": {"history": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RRC_HUBS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["hubs"],
|
||||
"properties": {"hubs": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RRC_SERVERS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["servers"],
|
||||
"properties": {"servers": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RRC_MESSAGES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["messages"],
|
||||
"properties": {"messages": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RRC_MEMBERS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["members"],
|
||||
"properties": {"members": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RRC_ACTIVITY_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["activity"],
|
||||
"properties": {"activity": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNCP_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {"status": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNCP_TRANSFER_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["transfer"],
|
||||
"properties": {"transfer": {"type": ["object", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNPATH_TABLE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["table"],
|
||||
"properties": {"table": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNPATH_RATES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["rates"],
|
||||
"properties": {"rates": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNPATH_TRACE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["trace"],
|
||||
"properties": {"trace": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNSH_SESSIONS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["sessions"],
|
||||
"properties": {"sessions": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNSH_OUTPUT_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["output"],
|
||||
"properties": {"output": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNSTATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["interfaces"],
|
||||
"properties": {"interfaces": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
BOTS_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["bots"],
|
||||
"properties": {"bots": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
BOTS_SUBPROCESS_LOG_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["log"],
|
||||
"properties": {"log": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
SPAM_KEYWORDS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["keywords"],
|
||||
"properties": {"keywords": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
SYSTEM_NETWORK_INTERFACES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["interfaces"],
|
||||
"properties": {"interfaces": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TRANSLATOR_LANGUAGES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["languages"],
|
||||
"properties": {"languages": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TOOLS_MICRON_PARSER_RELEASE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["release"],
|
||||
"properties": {"release": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TOOLS_RNODE_LATEST_RELEASE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["release"],
|
||||
"properties": {"release": _OBJECT},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
REPOSITORY_SERVER_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
REPOSITORY_SERVER_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["entries"],
|
||||
"properties": {"entries": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
ANNOUNCE_SINGLE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["announce"],
|
||||
"properties": {"announce": {"type": ["object", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
ANNOUNCES_LIST_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["announces"],
|
||||
"properties": {"announces": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DESTINATION_PATH_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {"path": {"type": ["object", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DESTINATION_DISPLAY_NAME_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["custom_display_name"],
|
||||
"properties": {"custom_display_name": {"type": ["string", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DESTINATION_STAMP_INFO_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["stamp_info"],
|
||||
"properties": {"stamp_info": {"type": ["object", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
DESTINATION_SIGNAL_METRICS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["metrics"],
|
||||
"properties": {"metrics": {"type": ["object", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_CONVERSATION_MESSAGES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["messages"],
|
||||
"properties": {"messages": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
LXMF_MESSAGE_URI_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["uri"],
|
||||
"properties": {"uri": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
IDENTITY_BACKUP_BASE32_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["base32"],
|
||||
"properties": {"base32": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
CHANGELOG_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["changelog"],
|
||||
"properties": {"changelog": _STRING},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEPHONE_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {"status": _INTEGER},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEPHONE_HISTORY_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["history"],
|
||||
"properties": {"history": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEPHONE_RECORDINGS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["recordings"],
|
||||
"properties": {"recordings": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEPHONE_AUDIO_PROFILES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["profiles"],
|
||||
"properties": {"profiles": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
TELEPHONE_CALL_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["call"],
|
||||
"properties": {"call": {"type": ["object", "null"]}},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
71
tests/backend/test_docker_runtime_smoke.py
Normal file
71
tests/backend/test_docker_runtime_smoke.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Docker image runtime smoke test (build + /api/v1/status)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-runtime-smoke.sh"
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "info"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
|
||||
reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker runtime smoke (build + /api/v1/status)",
|
||||
)
|
||||
@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
|
||||
def test_docker_image_serves_status():
|
||||
env = os.environ.copy()
|
||||
env.setdefault("MESHCHAT_DOCKER_SMOKE_TIMEOUT", "240")
|
||||
result = subprocess.run(
|
||||
["bash", str(_SMOKE_SCRIPT)],
|
||||
cwd=_REPO_ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=int(env["MESHCHAT_DOCKER_SMOKE_TIMEOUT"]) + 120,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"Docker runtime smoke failed (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
assert '"status": "ok"' in result.stdout or '"status":"ok"' in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
|
||||
reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker runtime smoke",
|
||||
)
|
||||
@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
|
||||
def test_docker_status_json_shape():
|
||||
"""Lightweight follow-up: status body matches minimal contract when smoke script left output."""
|
||||
sample = {"status": "ok", "listen_port": 8000, "https_enabled": True}
|
||||
from tests.backend.api_json_contract_schemas import API_V1_STATUS_SCHEMA, assert_matches_schema
|
||||
|
||||
assert_matches_schema(sample, API_V1_STATUS_SCHEMA)
|
||||
124
tests/backend/test_http_api_json_contracts_broad.py
Normal file
124
tests/backend/test_http_api_json_contracts_broad.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Broad JSON Schema contract tests for GET /api/v1 HTTP handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.backend.api_json_contract_schemas import assert_matches_schema
|
||||
from tests.backend.http_api_contract_helpers import load_route_fixture
|
||||
from tests.backend.http_api_contract_runtime import (
|
||||
HttpJsonContract,
|
||||
bootstrap_contract_app,
|
||||
invoke_json_contract,
|
||||
make_contract_app,
|
||||
make_minimal_identity,
|
||||
)
|
||||
from tests.backend.http_api_response_registry import (
|
||||
HTTP_JSON_GET_CONTRACTS,
|
||||
is_excluded_json_get_route,
|
||||
registered_get_paths,
|
||||
)
|
||||
|
||||
_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "http_api_routes.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def temp_dir():
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
dir_path = tempfile.mkdtemp()
|
||||
yield dir_path
|
||||
shutil.rmtree(dir_path)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mock_rns_minimal():
|
||||
with (
|
||||
patch("RNS.Reticulum") as mock_rns,
|
||||
patch("RNS.Transport"),
|
||||
patch("LXMF.LXMRouter"),
|
||||
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
|
||||
):
|
||||
mock_rns_instance = mock_rns.return_value
|
||||
mock_rns_instance.configpath = "/tmp/mock_config"
|
||||
mock_rns_instance.is_connected_to_shared_instance = False
|
||||
mock_rns_instance.transport_enabled.return_value = True
|
||||
yield make_minimal_identity()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def contract_app(mock_rns_minimal, temp_dir):
|
||||
with (
|
||||
patch("meshchatx.meshchat.generate_ssl_certificate"),
|
||||
patch("psutil.Process") as mock_process,
|
||||
patch("psutil.net_io_counters") as mock_net_io,
|
||||
patch("importlib.metadata.version", return_value="1.2.3"),
|
||||
patch("meshchatx.meshchat.LXST") as mock_lxst,
|
||||
patch("threading.Thread"),
|
||||
):
|
||||
mock_lxst.__version__ = "1.2.3"
|
||||
mock_proc_instance = mock_process.return_value
|
||||
mock_proc_instance.memory_info.return_value.rss = 1024
|
||||
mock_proc_instance.memory_info.return_value.vms = 2048
|
||||
mock_proc_instance.net_connections.return_value = []
|
||||
mock_net_instance = mock_net_io.return_value
|
||||
mock_net_instance.bytes_sent = 0
|
||||
mock_net_instance.bytes_recv = 0
|
||||
mock_net_instance.packets_sent = 0
|
||||
mock_net_instance.packets_recv = 0
|
||||
app = bootstrap_contract_app(make_contract_app(temp_dir, mock_rns_minimal))
|
||||
yield app
|
||||
|
||||
|
||||
def _validate_body(contract: HttpJsonContract, status: int, body) -> None:
|
||||
schemas = (contract.schema, *contract.alt_schemas)
|
||||
if status in contract.allow_statuses:
|
||||
for schema in schemas:
|
||||
try:
|
||||
assert_matches_schema(body, schema)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
raise AssertionError(
|
||||
f"{contract.method} {contract.path} status {status} body did not match "
|
||||
f"any allowed schema: {json.dumps(body)[:500]}",
|
||||
)
|
||||
raise AssertionError(
|
||||
f"{contract.method} {contract.path} returned unexpected status {status}: "
|
||||
f"{json.dumps(body)[:500]}",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"contract",
|
||||
HTTP_JSON_GET_CONTRACTS,
|
||||
ids=lambda c: f"{c.method} {c.path}",
|
||||
)
|
||||
async def test_get_json_response_matches_schema(contract_app, contract: HttpJsonContract):
|
||||
status, body = await invoke_json_contract(contract_app, contract)
|
||||
_validate_body(contract, status, body)
|
||||
|
||||
|
||||
def test_every_json_get_route_is_registered_or_excluded():
|
||||
routes = load_route_fixture(_FIXTURE)
|
||||
get_api = [r["path"] for r in routes if r["method"] == "GET" and r["path"].startswith("/api/v1")]
|
||||
registered = registered_get_paths()
|
||||
uncovered = []
|
||||
for path in get_api:
|
||||
if path in registered:
|
||||
continue
|
||||
if is_excluded_json_get_route(path):
|
||||
continue
|
||||
uncovered.append(path)
|
||||
assert not uncovered, (
|
||||
"GET /api/v1 routes missing from HTTP JSON contract registry "
|
||||
f"(add schema or exclude): {uncovered}"
|
||||
)
|
||||
|
|
@ -87,6 +87,7 @@ def test_parse_room_list_notice():
|
|||
def test_message_to_dict():
|
||||
msg = proto.RRCMessage("msg", "lobby", b"\xaa\xbb", "bob", "hi", 123)
|
||||
msg.mention = True
|
||||
msg.seq = 7
|
||||
assert msg.to_dict() == {
|
||||
"kind": "msg",
|
||||
"room": "lobby",
|
||||
|
|
@ -95,6 +96,7 @@ def test_message_to_dict():
|
|||
"text": "hi",
|
||||
"ts": 123,
|
||||
"mention": True,
|
||||
"seq": 7,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
99
tests/backend/test_ws_json_contracts.py
Normal file
99
tests/backend/test_ws_json_contracts.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""WebSocket JSON message contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from meshchatx.meshchat import ReticulumMeshChat
|
||||
from tests.backend.ws_contract_helpers import (
|
||||
extract_client_direct_response_types,
|
||||
extract_client_inbound_types,
|
||||
extract_server_broadcast_types,
|
||||
load_ws_manifest,
|
||||
write_ws_manifest,
|
||||
)
|
||||
from tests.backend.ws_json_contract_schemas import (
|
||||
WS_MESSAGE_SAMPLES,
|
||||
WS_MESSAGE_SCHEMAS,
|
||||
all_ws_message_types,
|
||||
assert_ws_message_matches_schema,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_MESHCHAT_PY = _REPO_ROOT / "meshchatx" / "meshchat.py"
|
||||
_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "ws_message_manifest.json"
|
||||
|
||||
|
||||
def _all_manifest_types(manifest: dict[str, list[str]]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for key in ("client_inbound", "client_direct_responses", "server_broadcast"):
|
||||
out.update(manifest.get(key, []))
|
||||
return out
|
||||
|
||||
|
||||
def test_ws_message_manifest_matches_meshchat():
|
||||
live = {
|
||||
"client_inbound": extract_client_inbound_types(_MESHCHAT_PY),
|
||||
"client_direct_responses": extract_client_direct_response_types(_MESHCHAT_PY),
|
||||
"server_broadcast": extract_server_broadcast_types(_MESHCHAT_PY),
|
||||
}
|
||||
if __import__("os").environ.get("UPDATE_WS_MESSAGE_MANIFEST") == "1":
|
||||
write_ws_manifest(_FIXTURE, live)
|
||||
pytest.skip(
|
||||
"UPDATE_WS_MESSAGE_MANIFEST=1: fixture updated; re-run without the env var",
|
||||
)
|
||||
expected = load_ws_manifest(_FIXTURE)
|
||||
assert live == expected, (
|
||||
"WebSocket message manifest drifted. Run: "
|
||||
"UPDATE_WS_MESSAGE_MANIFEST=1 uv run pytest "
|
||||
"tests/backend/test_ws_json_contracts.py -k manifest_matches_meshchat"
|
||||
)
|
||||
|
||||
|
||||
def test_every_manifest_type_has_schema():
|
||||
manifest = load_ws_manifest(_FIXTURE)
|
||||
missing = sorted(_all_manifest_types(manifest) - all_ws_message_types())
|
||||
assert not missing, f"Missing WebSocket schemas for: {missing}"
|
||||
|
||||
|
||||
def test_every_schema_has_sample_payload():
|
||||
missing = sorted(all_ws_message_types() - set(WS_MESSAGE_SAMPLES))
|
||||
assert not missing, f"Missing WebSocket sample payloads for: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("msg_type", sorted(WS_MESSAGE_SCHEMAS))
|
||||
def test_ws_schema_accepts_documented_sample(msg_type: str):
|
||||
sample = WS_MESSAGE_SAMPLES[msg_type]
|
||||
assert sample["type"] == msg_type
|
||||
assert_ws_message_matches_schema(sample)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_ping_emits_pong_contract():
|
||||
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
|
||||
client = MagicMock()
|
||||
sent: list[str] = []
|
||||
|
||||
async def capture_send(payload: str):
|
||||
sent.append(payload)
|
||||
|
||||
client.send_str = AsyncMock(side_effect=capture_send)
|
||||
|
||||
with patch("meshchatx.meshchat.AsyncUtils") as mock_async_utils:
|
||||
def run_async(coro):
|
||||
import asyncio
|
||||
|
||||
if asyncio.iscoroutine(coro):
|
||||
asyncio.get_event_loop().create_task(coro)
|
||||
|
||||
mock_async_utils.run_async.side_effect = run_async
|
||||
await app.on_websocket_data_received(client, {"type": "ping"})
|
||||
for payload in sent:
|
||||
assert_ws_message_matches_schema(json.loads(payload))
|
||||
assert json.loads(payload)["type"] == "pong"
|
||||
94
tests/backend/ws_contract_helpers.py
Normal file
94
tests/backend/ws_contract_helpers.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Helpers for WebSocket JSON message contract checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_CLIENT_HANDLER_RE = re.compile(
|
||||
r"(?:if|elif)\s+_type\s*==\s*[\"']([^\"']+)[\"']",
|
||||
)
|
||||
_WS_SEND_STR_RE = re.compile(
|
||||
r"client\.send_str\s*\(\s*json\.dumps\s*\(\s*(\{[\s\S]*?\})\s*,?\s*\)",
|
||||
)
|
||||
_WS_TYPE_LITERAL_RE = re.compile(
|
||||
r"[\"']type[\"']\s*:\s*[\"']([^\"']+)[\"']",
|
||||
)
|
||||
_BROADCAST_CALL_RE = re.compile(
|
||||
r"(?:websocket_broadcast|_broadcast_websocket_message)\s*\(",
|
||||
)
|
||||
|
||||
|
||||
def extract_client_inbound_types(meshchat_py: Path) -> list[str]:
|
||||
text = meshchat_py.read_text(encoding="utf-8")
|
||||
start = text.find("async def on_websocket_data_received")
|
||||
if start < 0:
|
||||
return []
|
||||
end = text.find("\n async def ", start + 1)
|
||||
if end < 0:
|
||||
end = text.find("\n def ", start + 1)
|
||||
block = text[start:end] if end > start else text[start:]
|
||||
types = _CLIENT_HANDLER_RE.findall(block)
|
||||
return sorted(set(types))
|
||||
|
||||
|
||||
def _extract_type_literals_from_dict_literal(blob: str) -> str | None:
|
||||
match = re.search(r"[\"']type[\"']\s*:\s*[\"']([^\"']+)[\"']", blob)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def extract_client_direct_response_types(meshchat_py: Path) -> list[str]:
|
||||
text = meshchat_py.read_text(encoding="utf-8")
|
||||
start = text.find("async def on_websocket_data_received")
|
||||
if start < 0:
|
||||
return []
|
||||
end = text.find("\n async def ", start + 1)
|
||||
block = text[start:end] if end > start else text[start:]
|
||||
types: set[str] = set()
|
||||
for blob in _WS_SEND_STR_RE.findall(block):
|
||||
msg_type = _extract_type_literals_from_dict_literal(blob)
|
||||
if msg_type:
|
||||
types.add(msg_type)
|
||||
return sorted(types)
|
||||
|
||||
|
||||
def extract_server_broadcast_types(meshchat_py: Path) -> list[str]:
|
||||
text = meshchat_py.read_text(encoding="utf-8")
|
||||
types: set[str] = set()
|
||||
for match in _BROADCAST_CALL_RE.finditer(text):
|
||||
chunk = text[match.end() : match.end() + 1200]
|
||||
type_match = _WS_TYPE_LITERAL_RE.search(chunk)
|
||||
if type_match:
|
||||
types.add(type_match.group(1))
|
||||
for fn_name in ("send_config_to_websocket_clients",):
|
||||
start = text.find(f"async def {fn_name}")
|
||||
if start < 0:
|
||||
continue
|
||||
end = text.find("\n async def ", start + 1)
|
||||
block = text[start:end] if end > start else text[start : start + 6000]
|
||||
for type_match in _WS_TYPE_LITERAL_RE.finditer(block):
|
||||
types.add(type_match.group(1))
|
||||
return sorted(types)
|
||||
|
||||
|
||||
def load_ws_manifest(fixture_path: Path) -> dict[str, list[str]]:
|
||||
data = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
for key in ("client_inbound", "client_direct_responses", "server_broadcast"):
|
||||
data[key] = sorted(data.get(key, []))
|
||||
return data
|
||||
|
||||
|
||||
def write_ws_manifest(fixture_path: Path, manifest: dict[str, list[str]]) -> None:
|
||||
fixture_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ordered = {
|
||||
"client_inbound": sorted(manifest.get("client_inbound", [])),
|
||||
"client_direct_responses": sorted(manifest.get("client_direct_responses", [])),
|
||||
"server_broadcast": sorted(manifest.get("server_broadcast", [])),
|
||||
}
|
||||
fixture_path.write_text(
|
||||
json.dumps(ordered, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
271
tests/backend/ws_json_contract_schemas.py
Normal file
271
tests/backend/ws_json_contract_schemas.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""JSON Schema definitions for WebSocket message bodies (contract tests)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.backend.api_json_contract_schemas import assert_matches_schema
|
||||
|
||||
|
||||
def _ws_type(
|
||||
msg_type: str,
|
||||
*,
|
||||
required: list[str] | None = None,
|
||||
properties: dict | None = None,
|
||||
additional_properties: bool = True,
|
||||
) -> dict:
|
||||
props: dict = {"type": {"type": "string", "const": msg_type}}
|
||||
if properties:
|
||||
props.update(properties)
|
||||
return {
|
||||
"type": "object",
|
||||
"required": ["type", *(required or [])],
|
||||
"properties": props,
|
||||
"additionalProperties": additional_properties,
|
||||
}
|
||||
|
||||
|
||||
_WS_OBJECT = {"type": "object", "additionalProperties": True}
|
||||
_WS_STRING = {"type": "string"}
|
||||
_WS_INTEGER = {"type": "integer"}
|
||||
_WS_BOOL = {"type": "boolean"}
|
||||
|
||||
WS_MESSAGE_SCHEMAS: dict[str, dict] = {
|
||||
"ping": _ws_type("ping"),
|
||||
"pong": _ws_type("pong"),
|
||||
"config.set": _ws_type("config.set", required=["config"], properties={"config": _WS_OBJECT}),
|
||||
"config": _ws_type("config", required=["config"], properties={"config": _WS_OBJECT}),
|
||||
"announced": _ws_type("announced"),
|
||||
"blocked_destinations": _ws_type(
|
||||
"blocked_destinations",
|
||||
required=["blocked_destinations"],
|
||||
properties={"blocked_destinations": {"type": "array", "items": _WS_OBJECT}},
|
||||
),
|
||||
"keyboard_shortcuts.get": _ws_type("keyboard_shortcuts.get"),
|
||||
"keyboard_shortcuts.set": _ws_type(
|
||||
"keyboard_shortcuts.set",
|
||||
required=["shortcuts"],
|
||||
properties={"shortcuts": _WS_OBJECT},
|
||||
),
|
||||
"keyboard_shortcuts.delete": _ws_type("keyboard_shortcuts.delete", required=["id"]),
|
||||
"keyboard_shortcuts": _ws_type(
|
||||
"keyboard_shortcuts",
|
||||
required=["shortcuts"],
|
||||
properties={"shortcuts": _WS_OBJECT},
|
||||
),
|
||||
"nomadnet.download.cancel": _ws_type(
|
||||
"nomadnet.download.cancel",
|
||||
required=["download_id"],
|
||||
properties={"download_id": _WS_INTEGER},
|
||||
),
|
||||
"nomadnet.download.cancelled": _ws_type(
|
||||
"nomadnet.download.cancelled",
|
||||
required=["download_id"],
|
||||
properties={"download_id": _WS_INTEGER},
|
||||
),
|
||||
"nomadnet.page.archives.get": _ws_type(
|
||||
"nomadnet.page.archives.get",
|
||||
required=["destination_hash", "page_path"],
|
||||
),
|
||||
"nomadnet.page.archives": _ws_type(
|
||||
"nomadnet.page.archives",
|
||||
required=["destination_hash", "page_path", "archives"],
|
||||
properties={"archives": {"type": "array", "items": _WS_OBJECT}},
|
||||
),
|
||||
"nomadnet.page.archive.load": _ws_type(
|
||||
"nomadnet.page.archive.load",
|
||||
required=["archive_id"],
|
||||
),
|
||||
"nomadnet.page.archive.flush": _ws_type("nomadnet.page.archive.flush"),
|
||||
"nomadnet.page.archive.add": _ws_type(
|
||||
"nomadnet.page.archive.add",
|
||||
required=["destination_hash", "page_path", "content"],
|
||||
),
|
||||
"nomadnet.page.archive.added": _ws_type(
|
||||
"nomadnet.page.archive.added",
|
||||
required=["destination_hash", "page_path"],
|
||||
),
|
||||
"nomadnet.file.download": _ws_type(
|
||||
"nomadnet.file.download",
|
||||
required=["download_id", "nomadnet_file_download"],
|
||||
properties={"nomadnet_file_download": _WS_OBJECT},
|
||||
),
|
||||
"nomadnet.page.download": _ws_type(
|
||||
"nomadnet.page.download",
|
||||
required=["download_id", "nomadnet_page_download"],
|
||||
properties={"nomadnet_page_download": _WS_OBJECT},
|
||||
),
|
||||
"lxmf.forwarding.rules.get": _ws_type("lxmf.forwarding.rules.get"),
|
||||
"lxmf.forwarding.rules": _ws_type(
|
||||
"lxmf.forwarding.rules",
|
||||
required=["rules"],
|
||||
properties={"rules": {"type": "array", "items": _WS_OBJECT}},
|
||||
),
|
||||
"lxmf.forwarding.rule.add": _ws_type(
|
||||
"lxmf.forwarding.rule.add",
|
||||
required=["rule"],
|
||||
properties={"rule": _WS_OBJECT},
|
||||
),
|
||||
"lxmf.forwarding.rule.delete": _ws_type(
|
||||
"lxmf.forwarding.rule.delete",
|
||||
required=["rule_id"],
|
||||
),
|
||||
"lxmf.forwarding.rule.toggle": _ws_type(
|
||||
"lxmf.forwarding.rule.toggle",
|
||||
required=["rule_id", "enabled"],
|
||||
properties={"enabled": _WS_BOOL},
|
||||
),
|
||||
"lxm.ingest_uri": _ws_type("lxm.ingest_uri", required=["uri"]),
|
||||
"lxm.ingest_uri.result": _ws_type(
|
||||
"lxm.ingest_uri.result",
|
||||
required=["success"],
|
||||
properties={"success": _WS_BOOL},
|
||||
),
|
||||
"lxm.generate_paper_uri": _ws_type("lxm.generate_paper_uri"),
|
||||
"lxm.generate_paper_uri.result": _ws_type(
|
||||
"lxm.generate_paper_uri.result",
|
||||
required=["success"],
|
||||
properties={"success": _WS_BOOL},
|
||||
),
|
||||
"announce": _ws_type("announce", required=["announce"], properties={"announce": _WS_OBJECT}),
|
||||
"lxmf.delivery": _ws_type("lxmf.delivery"),
|
||||
"lxmf_message_created": _ws_type(
|
||||
"lxmf_message_created",
|
||||
required=["lxmf_message"],
|
||||
properties={"lxmf_message": _WS_OBJECT},
|
||||
),
|
||||
"lxmf_message_state_updated": _ws_type(
|
||||
"lxmf_message_state_updated",
|
||||
required=["lxmf_message"],
|
||||
properties={"lxmf_message": _WS_OBJECT},
|
||||
),
|
||||
"lxmf_message_deleted": _ws_type("lxmf_message_deleted"),
|
||||
"lxmf.telemetry": _ws_type("lxmf.telemetry", required=["destination_hash"]),
|
||||
"identity_switched": _ws_type("identity_switched"),
|
||||
"reticulum_reload_status": _ws_type("reticulum_reload_status", required=["status"]),
|
||||
"new_voicemail": _ws_type("new_voicemail"),
|
||||
"telephone_ringing": _ws_type("telephone_ringing"),
|
||||
"telephone_call_established": _ws_type("telephone_call_established"),
|
||||
"telephone_missed_call": _ws_type("telephone_missed_call"),
|
||||
"telephone_call_ended": _ws_type("telephone_call_ended"),
|
||||
"telephone_initiation_status": _ws_type(
|
||||
"telephone_initiation_status",
|
||||
required=["status"],
|
||||
),
|
||||
"rrc.change": _ws_type("rrc.change"),
|
||||
"rrc.message": _ws_type("rrc.message", required=["message"], properties={"message": _WS_OBJECT}),
|
||||
"rrc.server.change": _ws_type("rrc.server.change"),
|
||||
"rnsh.session.change": _ws_type("rnsh.session.change"),
|
||||
"rnsh.output": _ws_type("rnsh.output"),
|
||||
"rncp.transfer.progress": _ws_type("rncp.transfer.progress"),
|
||||
"rncp.send.completed": _ws_type("rncp.send.completed"),
|
||||
"rncp.fetch.completed": _ws_type("rncp.fetch.completed"),
|
||||
}
|
||||
|
||||
WS_MESSAGE_SAMPLES: dict[str, dict] = {
|
||||
"ping": {"type": "ping"},
|
||||
"pong": {"type": "pong"},
|
||||
"config.set": {"type": "config.set", "config": {"display_name": "Test"}},
|
||||
"config": {"type": "config", "config": {"display_name": "Test"}},
|
||||
"announced": {"type": "announced"},
|
||||
"blocked_destinations": {"type": "blocked_destinations", "blocked_destinations": []},
|
||||
"keyboard_shortcuts.get": {"type": "keyboard_shortcuts.get"},
|
||||
"keyboard_shortcuts.set": {"type": "keyboard_shortcuts.set", "shortcuts": {}},
|
||||
"keyboard_shortcuts.delete": {"type": "keyboard_shortcuts.delete", "id": "nav_messages"},
|
||||
"keyboard_shortcuts": {"type": "keyboard_shortcuts", "shortcuts": {}},
|
||||
"nomadnet.download.cancel": {"type": "nomadnet.download.cancel", "download_id": 1},
|
||||
"nomadnet.download.cancelled": {"type": "nomadnet.download.cancelled", "download_id": 1},
|
||||
"nomadnet.page.archives.get": {
|
||||
"type": "nomadnet.page.archives.get",
|
||||
"destination_hash": "aa" * 16,
|
||||
"page_path": "/page/index.mu",
|
||||
},
|
||||
"nomadnet.page.archives": {
|
||||
"type": "nomadnet.page.archives",
|
||||
"destination_hash": "aa" * 16,
|
||||
"page_path": "/page/index.mu",
|
||||
"archives": [],
|
||||
},
|
||||
"nomadnet.page.archive.load": {"type": "nomadnet.page.archive.load", "archive_id": 1},
|
||||
"nomadnet.page.archive.flush": {"type": "nomadnet.page.archive.flush"},
|
||||
"nomadnet.page.archive.add": {
|
||||
"type": "nomadnet.page.archive.add",
|
||||
"destination_hash": "aa" * 16,
|
||||
"page_path": "/page/index.mu",
|
||||
"content": "body",
|
||||
},
|
||||
"nomadnet.page.archive.added": {
|
||||
"type": "nomadnet.page.archive.added",
|
||||
"destination_hash": "aa" * 16,
|
||||
"page_path": "/page/index.mu",
|
||||
},
|
||||
"nomadnet.file.download": {
|
||||
"type": "nomadnet.file.download",
|
||||
"download_id": 1,
|
||||
"nomadnet_file_download": {"status": "success"},
|
||||
},
|
||||
"nomadnet.page.download": {
|
||||
"type": "nomadnet.page.download",
|
||||
"download_id": 1,
|
||||
"nomadnet_page_download": {"status": "success"},
|
||||
},
|
||||
"lxmf.forwarding.rules.get": {"type": "lxmf.forwarding.rules.get"},
|
||||
"lxmf.forwarding.rules": {"type": "lxmf.forwarding.rules", "rules": []},
|
||||
"lxmf.forwarding.rule.add": {"type": "lxmf.forwarding.rule.add", "rule": {"id": 1}},
|
||||
"lxmf.forwarding.rule.delete": {"type": "lxmf.forwarding.rule.delete", "rule_id": 1},
|
||||
"lxmf.forwarding.rule.toggle": {
|
||||
"type": "lxmf.forwarding.rule.toggle",
|
||||
"rule_id": 1,
|
||||
"enabled": True,
|
||||
},
|
||||
"lxm.ingest_uri": {"type": "lxm.ingest_uri", "uri": "meshchatx://map"},
|
||||
"lxm.ingest_uri.result": {"type": "lxm.ingest_uri.result", "success": True},
|
||||
"lxm.generate_paper_uri": {"type": "lxm.generate_paper_uri"},
|
||||
"lxm.generate_paper_uri.result": {
|
||||
"type": "lxm.generate_paper_uri.result",
|
||||
"success": True,
|
||||
"uri": "lxm://paper",
|
||||
},
|
||||
"announce": {"type": "announce", "announce": {"aspect": "lxmf.delivery"}},
|
||||
"lxmf.delivery": {"type": "lxmf.delivery"},
|
||||
"lxmf_message_created": {
|
||||
"type": "lxmf_message_created",
|
||||
"lxmf_message": {"hash": "cc" * 16},
|
||||
},
|
||||
"lxmf_message_state_updated": {
|
||||
"type": "lxmf_message_state_updated",
|
||||
"lxmf_message": {"hash": "cc" * 16, "state": "sent"},
|
||||
},
|
||||
"lxmf_message_deleted": {"type": "lxmf_message_deleted", "hash": "cc" * 16},
|
||||
"lxmf.telemetry": {"type": "lxmf.telemetry", "destination_hash": "aa" * 16},
|
||||
"identity_switched": {"type": "identity_switched"},
|
||||
"reticulum_reload_status": {"type": "reticulum_reload_status", "status": "ok"},
|
||||
"new_voicemail": {"type": "new_voicemail", "id": 1},
|
||||
"telephone_ringing": {"type": "telephone_ringing", "remote_identity_hash": "aa" * 16},
|
||||
"telephone_call_established": {"type": "telephone_call_established"},
|
||||
"telephone_missed_call": {"type": "telephone_missed_call"},
|
||||
"telephone_call_ended": {"type": "telephone_call_ended"},
|
||||
"telephone_initiation_status": {"type": "telephone_initiation_status", "status": "ringing"},
|
||||
"rrc.change": {"type": "rrc.change"},
|
||||
"rrc.message": {"type": "rrc.message", "message": {"kind": "msg"}},
|
||||
"rrc.server.change": {"type": "rrc.server.change"},
|
||||
"rnsh.session.change": {"type": "rnsh.session.change"},
|
||||
"rnsh.output": {"type": "rnsh.output", "data": "ok"},
|
||||
"rncp.transfer.progress": {"type": "rncp.transfer.progress", "percent": 50},
|
||||
"rncp.send.completed": {"type": "rncp.send.completed"},
|
||||
"rncp.fetch.completed": {"type": "rncp.fetch.completed"},
|
||||
}
|
||||
|
||||
|
||||
def all_ws_message_types() -> set[str]:
|
||||
return set(WS_MESSAGE_SCHEMAS)
|
||||
|
||||
|
||||
def assert_ws_message_matches_schema(message: dict) -> None:
|
||||
msg_type = message.get("type")
|
||||
if not isinstance(msg_type, str):
|
||||
raise AssertionError("WebSocket message missing string type")
|
||||
schema = WS_MESSAGE_SCHEMAS.get(msg_type)
|
||||
if schema is None:
|
||||
raise AssertionError(f"No WebSocket schema registered for type {msg_type!r}")
|
||||
assert_matches_schema(message, schema)
|
||||
19
tests/frontend/__snapshots__/uiSnapshots.test.js.snap
Normal file
19
tests/frontend/__snapshots__/uiSnapshots.test.js.snap
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`UI snapshot regression > ConfirmDialog.vue > visible confirm dialog 1`] = `"<div="" class="fixed inset-0 z-9999 flex items-center justify-center p-4 confirm-dialog-enter-from confirm-dialog-enter-active"><div="" class="fixed inset-0 bg-black/50 backdrop-blur-xs shadow-2xl"></div><div="" class="relative w-full sm:w-auto sm:min-w-[400px] sm:max-w-md bg-white dark:bg-zinc-900 sm:rounded-3xl rounded-3xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transform transition-all"><div="" class="p-8"><div="" class="flex items-start mb-6"><div="" class="shrink-0 flex items-center justify-center w-12 h-12 rounded-2xl bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 mr-4"><span="" class="mdi-snapshot w-6 h-6" data-icon="alert-circle"></span></div><div="" class="flex-1 min-w-0"><h3="" class="text-xl font-black text-gray-900 dark:text-white mb-2">Confirm Action</h3><p="" class="text-gray-600 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">Delete this item?</p></div></div><div="" class="flex flex-col sm:flex-row gap-3 sm:justify-end mt-8"><button="" type="button" class="px-6 py-3 text-sm font-bold text-gray-700 dark:text-zinc-300 bg-gray-100 dark:bg-zinc-800 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-95"> Cancel </button><button="" type="button" class="px-6 py-3 text-sm font-bold text-white bg-red-600 hover:bg-red-700 rounded-xl shadow-lg shadow-red-600/20 transition-all active:scale-95"> Confirm </button></div></div></div></div>"`;
|
||||
|
||||
exports[`UI snapshot regression > FormLabel.vue > default label 1`] = `"<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">Display name</label>"`;
|
||||
|
||||
exports[`UI snapshot regression > FormSubLabel.vue > default sub-label 1`] = `"<div class="text-xs text-gray-600 dark:text-zinc-300">Optional helper text</div>"`;
|
||||
|
||||
exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-9 h-9 flex items-center justify-center shrink-0 transition-all duration-200"><span class="icon">+</span></button>"`;
|
||||
|
||||
exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" class="hover:bg-gray-100 dark:hover:bg-zinc-700 w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500 overflow-hidden"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`;
|
||||
|
||||
exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
|
||||
|
||||
exports[`UI snapshot regression > Toast.vue > error toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
|
||||
|
||||
exports[`UI snapshot regression > Toast.vue > loading toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
|
||||
|
||||
exports[`UI snapshot regression > Toast.vue > success toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
|
||||
49
tests/frontend/snapshotTestUtils.js
Normal file
49
tests/frontend/snapshotTestUtils.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { mount } from "@vue/test-utils";
|
||||
|
||||
export const snapshotMaterialDesignIcon = {
|
||||
name: "MaterialDesignIcon",
|
||||
template: '<span class="mdi-snapshot" :data-icon="iconName"></span>',
|
||||
props: ["iconName"],
|
||||
};
|
||||
|
||||
export const snapshotTransitionGroup = {
|
||||
template: "<div class=\"snapshot-transition-group\"><slot /></div>",
|
||||
};
|
||||
|
||||
export function snapshotI18nMock() {
|
||||
return (key) => key;
|
||||
}
|
||||
|
||||
export function normalizeSnapshotHtml(html) {
|
||||
return String(html)
|
||||
.replace(/\sdata-v-[a-f0-9]+/g, "")
|
||||
.replace(/\s+id="[^"]*"/g, "")
|
||||
.replace(/\s+style="[^"]*"/g, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.replace(/>\s+</g, "><")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function mountSnapshot(component, options = {}) {
|
||||
const { props, slots, global = {} } = options;
|
||||
return mount(component, {
|
||||
props,
|
||||
slots,
|
||||
global: {
|
||||
mocks: {
|
||||
$t: snapshotI18nMock(),
|
||||
...(global.mocks || {}),
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: snapshotMaterialDesignIcon,
|
||||
TransitionGroup: snapshotTransitionGroup,
|
||||
...(global.stubs || {}),
|
||||
},
|
||||
...global,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function expectHtmlSnapshot(wrapper) {
|
||||
expect(normalizeSnapshotHtml(wrapper.html())).toMatchSnapshot();
|
||||
}
|
||||
118
tests/frontend/uiSnapshots.test.js
Normal file
118
tests/frontend/uiSnapshots.test.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import Toast from "@/components/Toast.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import FormLabel from "@/components/forms/FormLabel.vue";
|
||||
import FormSubLabel from "@/components/forms/FormSubLabel.vue";
|
||||
import IconButton from "@/components/IconButton.vue";
|
||||
import SidebarLink from "@/components/SidebarLink.vue";
|
||||
import GlobalEmitter from "@/js/GlobalEmitter";
|
||||
import { expectHtmlSnapshot, mountSnapshot } from "./snapshotTestUtils.js";
|
||||
|
||||
vi.mock("@/js/GlobalEmitter", () => ({
|
||||
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
|
||||
}));
|
||||
|
||||
const RouterLinkStub = {
|
||||
name: "RouterLinkStub",
|
||||
props: ["to"],
|
||||
template: '<a class="router-link-stub" href="#"><slot :href="\'#\'" :navigate="() => {}" :isActive="false"/></a>',
|
||||
};
|
||||
|
||||
describe("UI snapshot regression", () => {
|
||||
describe("Toast.vue", () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
wrapper = mountSnapshot(Toast);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("empty state", () => {
|
||||
expectHtmlSnapshot(wrapper);
|
||||
});
|
||||
|
||||
it("success toast", async () => {
|
||||
GlobalEmitter.emit("toast", { message: "Saved", type: "success", duration: 0 });
|
||||
await wrapper.vm.$nextTick();
|
||||
expectHtmlSnapshot(wrapper);
|
||||
});
|
||||
|
||||
it("error toast", async () => {
|
||||
GlobalEmitter.emit("toast", { message: "Failed", type: "error", duration: 0 });
|
||||
await wrapper.vm.$nextTick();
|
||||
expectHtmlSnapshot(wrapper);
|
||||
});
|
||||
|
||||
it("loading toast", async () => {
|
||||
GlobalEmitter.emit("toast", {
|
||||
message: "Working",
|
||||
type: "loading",
|
||||
duration: 0,
|
||||
key: "job-1",
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expectHtmlSnapshot(wrapper);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmDialog.vue", () => {
|
||||
it("visible confirm dialog", async () => {
|
||||
const wrapper = mountSnapshot(ConfirmDialog);
|
||||
const showFn = vi.mocked(GlobalEmitter.on).mock.calls.find((c) => c[0] === "confirm")?.[1];
|
||||
showFn?.({ message: "Delete this item?", resolve: vi.fn() });
|
||||
await wrapper.vm.$nextTick();
|
||||
expectHtmlSnapshot(wrapper);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormLabel.vue", () => {
|
||||
it("default label", () => {
|
||||
const wrapper = mountSnapshot(FormLabel, {
|
||||
slots: { default: "Display name" },
|
||||
});
|
||||
expectHtmlSnapshot(wrapper);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormSubLabel.vue", () => {
|
||||
it("default sub-label", () => {
|
||||
const wrapper = mountSnapshot(FormSubLabel, {
|
||||
slots: { default: "Optional helper text" },
|
||||
});
|
||||
expectHtmlSnapshot(wrapper);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IconButton.vue", () => {
|
||||
it("icon button", () => {
|
||||
const wrapper = mountSnapshot(IconButton, {
|
||||
slots: { default: '<span class="icon">+</span>' },
|
||||
});
|
||||
expectHtmlSnapshot(wrapper);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarLink.vue", () => {
|
||||
it("sidebar link", () => {
|
||||
const wrapper = mountSnapshot(SidebarLink, {
|
||||
props: { to: { name: "messages" } },
|
||||
slots: {
|
||||
icon: '<span class="icon-slot">M</span>',
|
||||
text: "Messages",
|
||||
},
|
||||
global: { stubs: { RouterLink: RouterLinkStub } },
|
||||
});
|
||||
expectHtmlSnapshot(wrapper);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue