mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 11:02:56 +02:00
Add contact blocking
This commit is contained in:
@@ -275,6 +275,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 34)
|
||||
applied += 1
|
||||
|
||||
# Migration 35: Add blocked_keys and blocked_names columns to app_settings
|
||||
if version < 35:
|
||||
logger.info("Applying migration 35: add blocked_keys and blocked_names to app_settings")
|
||||
await _migrate_035_add_block_lists(conn)
|
||||
await set_version(conn, 35)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -1976,3 +1983,34 @@ async def _migrate_034_add_flood_scope(conn: aiosqlite.Connection) -> None:
|
||||
logger.debug("app_settings table not ready, skipping flood_scope migration")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_035_add_block_lists(conn: aiosqlite.Connection) -> None:
|
||||
"""Add blocked_keys and blocked_names columns to app_settings.
|
||||
|
||||
These store JSON arrays of blocked public keys and display names.
|
||||
Blocking hides messages from the UI but does not affect MQTT or bots.
|
||||
"""
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_keys TEXT DEFAULT '[]'")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "duplicate column" in error_msg:
|
||||
logger.debug("blocked_keys column already exists, skipping")
|
||||
elif "no such table" in error_msg:
|
||||
logger.debug("app_settings table not ready, skipping blocked_keys migration")
|
||||
else:
|
||||
raise
|
||||
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_names TEXT DEFAULT '[]'")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "duplicate column" in error_msg:
|
||||
logger.debug("blocked_names column already exists, skipping")
|
||||
elif "no such table" in error_msg:
|
||||
logger.debug("app_settings table not ready, skipping blocked_names migration")
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
@@ -522,6 +522,14 @@ class AppSettings(BaseModel):
|
||||
default="",
|
||||
description="Outbound flood scope / region name (empty = disabled, no tagging)",
|
||||
)
|
||||
blocked_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
)
|
||||
blocked_names: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
|
||||
@@ -170,10 +170,31 @@ class MessageRepository:
|
||||
after: int | None = None,
|
||||
after_id: int | None = None,
|
||||
q: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND sender_name IS NOT NULL"
|
||||
f" AND sender_name IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_names)
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
params.append(msg_type)
|
||||
@@ -214,6 +235,8 @@ class MessageRepository:
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
context_size: int = 100,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> tuple[list[Message], bool, bool]:
|
||||
"""Get messages around a target message.
|
||||
|
||||
@@ -231,6 +254,24 @@ class MessageRepository:
|
||||
where_parts.append(clause.removeprefix("AND "))
|
||||
base_params.append(norm_key)
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
where_parts.append(
|
||||
f"NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
base_params.extend(blocked_keys)
|
||||
base_params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
where_parts.append(
|
||||
f"NOT (outgoing=0 AND sender_name IS NOT NULL AND sender_name IN ({placeholders}))"
|
||||
)
|
||||
base_params.extend(blocked_names)
|
||||
|
||||
where_sql = " AND ".join(["1=1", *where_parts])
|
||||
|
||||
# 1. Get the target message (must satisfy filters if provided)
|
||||
|
||||
@@ -32,7 +32,8 @@ class AppSettingsRepository:
|
||||
mqtt_publish_messages, mqtt_publish_raw_packets,
|
||||
community_mqtt_enabled, community_mqtt_iata,
|
||||
community_mqtt_broker_host, community_mqtt_broker_port,
|
||||
community_mqtt_email, flood_scope
|
||||
community_mqtt_email, flood_scope,
|
||||
blocked_keys, blocked_names
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -82,6 +83,22 @@ class AppSettingsRepository:
|
||||
)
|
||||
bots = []
|
||||
|
||||
# Parse blocked_keys JSON
|
||||
blocked_keys: list[str] = []
|
||||
if row["blocked_keys"]:
|
||||
try:
|
||||
blocked_keys = json.loads(row["blocked_keys"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_keys = []
|
||||
|
||||
# Parse blocked_names JSON
|
||||
blocked_names: list[str] = []
|
||||
if row["blocked_names"]:
|
||||
try:
|
||||
blocked_names = json.loads(row["blocked_names"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_names = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
if sort_order not in ("recent", "alpha"):
|
||||
@@ -113,6 +130,8 @@ class AppSettingsRepository:
|
||||
community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
|
||||
community_mqtt_email=row["community_mqtt_email"] or "",
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -141,6 +160,8 @@ class AppSettingsRepository:
|
||||
community_mqtt_broker_port: int | None = None,
|
||||
community_mqtt_email: str | None = None,
|
||||
flood_scope: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -244,6 +265,14 @@ class AppSettingsRepository:
|
||||
updates.append("flood_scope = ?")
|
||||
params.append(flood_scope)
|
||||
|
||||
if blocked_keys is not None:
|
||||
updates.append("blocked_keys = ?")
|
||||
params.append(json.dumps(blocked_keys))
|
||||
|
||||
if blocked_names is not None:
|
||||
updates.append("blocked_names = ?")
|
||||
params.append(json.dumps(blocked_names))
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
@@ -272,6 +301,27 @@ class AppSettingsRepository:
|
||||
]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def toggle_blocked_key(key: str) -> AppSettings:
|
||||
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
|
||||
normalized = key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
if normalized in settings.blocked_keys:
|
||||
new_keys = [k for k in settings.blocked_keys if k != normalized]
|
||||
else:
|
||||
new_keys = settings.blocked_keys + [normalized]
|
||||
return await AppSettingsRepository.update(blocked_keys=new_keys)
|
||||
|
||||
@staticmethod
|
||||
async def toggle_blocked_name(name: str) -> AppSettings:
|
||||
"""Toggle a display name in the blocked list."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
if name in settings.blocked_names:
|
||||
new_names = [n for n in settings.blocked_names if n != name]
|
||||
else:
|
||||
new_names = settings.blocked_names + [name]
|
||||
return await AppSettingsRepository.update(blocked_names=new_names)
|
||||
|
||||
@staticmethod
|
||||
async def migrate_preferences_from_frontend(
|
||||
favorites: list[dict],
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.models import (
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,11 +29,16 @@ async def get_messages_around(
|
||||
context: int = Query(default=100, ge=1, le=500, description="Number of messages before/after"),
|
||||
) -> MessagesAroundResponse:
|
||||
"""Get messages around a specific message for jump-to-message navigation."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
messages, has_older, has_newer = await MessageRepository.get_around(
|
||||
message_id=message_id,
|
||||
msg_type=type,
|
||||
conversation_key=conversation_key,
|
||||
context_size=context,
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
return MessagesAroundResponse(messages=messages, has_older=has_older, has_newer=has_newer)
|
||||
|
||||
@@ -59,6 +64,9 @@ async def list_messages(
|
||||
q: str | None = Query(default=None, description="Full-text search query"),
|
||||
) -> list[Message]:
|
||||
"""List messages from the database."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
return await MessageRepository.get_all(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -69,6 +77,8 @@ async def list_messages(
|
||||
after=after,
|
||||
after_id=after_id,
|
||||
q=q,
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -125,6 +125,22 @@ class AppSettingsUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Outbound flood scope / region name (empty = disabled)",
|
||||
)
|
||||
blocked_keys: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
)
|
||||
blocked_names: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
|
||||
|
||||
class BlockKeyRequest(BaseModel):
|
||||
key: str = Field(description="Public key to toggle block status")
|
||||
|
||||
|
||||
class BlockNameRequest(BaseModel):
|
||||
name: str = Field(description="Display name to toggle block status")
|
||||
|
||||
|
||||
class FavoriteRequest(BaseModel):
|
||||
@@ -241,6 +257,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
kwargs["community_mqtt_email"] = update.community_mqtt_email
|
||||
community_mqtt_changed = True
|
||||
|
||||
# Block lists
|
||||
if update.blocked_keys is not None:
|
||||
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
|
||||
if update.blocked_names is not None:
|
||||
kwargs["blocked_names"] = update.blocked_names
|
||||
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
@@ -317,6 +339,20 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
||||
"""Toggle a public key's blocked status."""
|
||||
logger.info("Toggling blocked key: %s", request.key[:12])
|
||||
return await AppSettingsRepository.toggle_blocked_key(request.key)
|
||||
|
||||
|
||||
@router.post("/blocked-names/toggle", response_model=AppSettings)
|
||||
async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
|
||||
"""Toggle a display name's blocked status."""
|
||||
logger.info("Toggling blocked name: %s", request.name)
|
||||
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
||||
"""Migrate all preferences from frontend localStorage to database.
|
||||
|
||||
@@ -117,6 +117,8 @@ export function App() {
|
||||
handleSaveAppSettings,
|
||||
handleSortOrderChange,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
} = useAppSettings();
|
||||
|
||||
// Keep user's name in ref for mention detection in WebSocket callback
|
||||
@@ -125,6 +127,14 @@ export function App() {
|
||||
myNameRef.current = config?.name ?? null;
|
||||
}, [config?.name]);
|
||||
|
||||
// Keep block lists in refs for WS callback filtering
|
||||
const blockedKeysRef = useRef<string[]>([]);
|
||||
const blockedNamesRef = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
blockedKeysRef.current = appSettings?.blocked_keys ?? [];
|
||||
blockedNamesRef.current = appSettings?.blocked_names ?? [];
|
||||
}, [appSettings?.blocked_keys, appSettings?.blocked_names]);
|
||||
|
||||
// Check if a message mentions the user
|
||||
const checkMention = useCallback(
|
||||
(text: string): boolean => messageContainsMention(text, myNameRef.current),
|
||||
@@ -256,6 +266,21 @@ export function App() {
|
||||
.catch(console.error);
|
||||
},
|
||||
onMessage: (msg: Message) => {
|
||||
// Filter blocked contacts on incoming (non-outgoing) messages
|
||||
if (!msg.outgoing) {
|
||||
const bKeys = blockedKeysRef.current;
|
||||
const bNames = blockedNamesRef.current;
|
||||
// Block DMs by key
|
||||
if (
|
||||
bKeys.length > 0 &&
|
||||
msg.type === 'PRIV' &&
|
||||
bKeys.includes(msg.conversation_key.toLowerCase())
|
||||
)
|
||||
return;
|
||||
// Block by sender name (works for channel messages)
|
||||
if (bNames.length > 0 && msg.sender_name && bNames.includes(msg.sender_name)) return;
|
||||
}
|
||||
|
||||
const activeConv = activeConversationRef.current;
|
||||
|
||||
// Check if message belongs to the active conversation
|
||||
@@ -441,6 +466,28 @@ export function App() {
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
// Wrappers that clear cache and hard-refetch messages after block changes.
|
||||
// jumpToBottom does cache.remove + fetchMessages(true) which fully replaces
|
||||
// the message state; triggerReconcile only merges diffs and would keep
|
||||
// blocked messages already in state.
|
||||
const handleBlockKey = useCallback(
|
||||
async (key: string) => {
|
||||
await handleToggleBlockedKey(key);
|
||||
messageCache.clear();
|
||||
jumpToBottom();
|
||||
},
|
||||
[handleToggleBlockedKey, jumpToBottom]
|
||||
);
|
||||
|
||||
const handleBlockName = useCallback(
|
||||
async (name: string) => {
|
||||
await handleToggleBlockedName(name);
|
||||
messageCache.clear();
|
||||
jumpToBottom();
|
||||
},
|
||||
[handleToggleBlockedName, jumpToBottom]
|
||||
);
|
||||
|
||||
const handleCloseSettingsView = useCallback(() => {
|
||||
startTransition(() => setShowSettings(false));
|
||||
setSidebarOpen(false);
|
||||
@@ -796,6 +843,10 @@ export function App() {
|
||||
onHealthRefresh={handleHealthRefresh}
|
||||
onRefreshAppSettings={fetchAppSettings}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
blockedKeys={appSettings?.blocked_keys}
|
||||
blockedNames={appSettings?.blocked_names}
|
||||
onToggleBlockedKey={handleBlockKey}
|
||||
onToggleBlockedName={handleBlockName}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -861,6 +912,10 @@ export function App() {
|
||||
favorites={favorites}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onNavigateToChannel={handleNavigateToChannel}
|
||||
blockedKeys={appSettings?.blocked_keys}
|
||||
blockedNames={appSettings?.blocked_names}
|
||||
onToggleBlockedKey={handleBlockKey}
|
||||
onToggleBlockedName={handleBlockName}
|
||||
/>
|
||||
|
||||
<ChannelInfoPane
|
||||
|
||||
@@ -254,6 +254,18 @@ export const api = {
|
||||
body: JSON.stringify(settings),
|
||||
}),
|
||||
|
||||
// Block lists
|
||||
toggleBlockedKey: (key: string) =>
|
||||
fetchJson<AppSettings>('/settings/blocked-keys/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
toggleBlockedName: (name: string) =>
|
||||
fetchJson<AppSettings>('/settings/blocked-names/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||
|
||||
@@ -26,6 +26,10 @@ interface ContactInfoPaneProps {
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
}
|
||||
|
||||
export function ContactInfoPane({
|
||||
@@ -36,17 +40,23 @@ export function ContactInfoPane({
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
}: ContactInfoPaneProps) {
|
||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
||||
|
||||
const [detail, setDetail] = useState<ContactDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get live contact data from contacts array (real-time via WS)
|
||||
const liveContact = contactKey
|
||||
? (contacts.find((c) => c.public_key === contactKey) ?? null)
|
||||
: null;
|
||||
const liveContact =
|
||||
contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!contactKey) {
|
||||
if (!contactKey || isNameOnly) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
@@ -70,7 +80,7 @@ export function ContactInfoPane({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [contactKey]);
|
||||
}, [contactKey, isNameOnly]);
|
||||
|
||||
// Use live contact data where available, fall back to detail snapshot
|
||||
const contact = liveContact ?? detail?.contact ?? null;
|
||||
@@ -90,7 +100,46 @@ export function ContactInfoPane({
|
||||
<SheetTitle>Contact Info</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !detail ? (
|
||||
{isNameOnly && nameOnlyValue ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Name-only header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-border">
|
||||
<div className="flex items-start gap-4">
|
||||
<ContactAvatar name={nameOnlyValue} publicKey={`name:${nameOnlyValue}`} size={56} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{nameOnlyValue}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
We have not heard an advertisement associated with this name, so we cannot
|
||||
identify their key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block by name toggle */}
|
||||
{onToggleBlockedName && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleBlockedName(nameOnlyValue)}
|
||||
>
|
||||
{blockedNames.includes(nameOnlyValue) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<span>Unblock this name</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<span>Block this name</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : loading && !detail ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
@@ -209,6 +258,50 @@ export function ContactInfoPane({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block toggles */}
|
||||
{(onToggleBlockedKey || onToggleBlockedName) && (
|
||||
<div className="px-5 py-3 border-b border-border space-y-2">
|
||||
{onToggleBlockedKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleBlockedKey(contact.public_key)}
|
||||
>
|
||||
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<span>Unblock this key</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<span>Block this key</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onToggleBlockedName && contact.name && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleBlockedName(contact.name!)}
|
||||
>
|
||||
{blockedNames.includes(contact.name) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<span>Unblock name “{contact.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<span>Block name “{contact.name}”</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{detail && detail.name_history.length > 1 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
|
||||
@@ -530,26 +530,16 @@ export function MessageList({
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<span
|
||||
role={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:') ? 'button' : undefined
|
||||
}
|
||||
tabIndex={onOpenContactInfo && !avatarKey.startsWith('name:') ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:')
|
||||
? handleKeyboardActivate
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:')
|
||||
? () => onOpenContactInfo(avatarKey)
|
||||
: undefined
|
||||
}
|
||||
role={onOpenContactInfo ? 'button' : undefined}
|
||||
tabIndex={onOpenContactInfo ? 0 : undefined}
|
||||
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
|
||||
onClick={onOpenContactInfo ? () => onOpenContactInfo(avatarKey) : undefined}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable={!!onOpenContactInfo && !avatarKey.startsWith('name:')}
|
||||
clickable={!!onOpenContactInfo}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -33,6 +33,10 @@ interface SettingsModalBaseProps {
|
||||
onHealthRefresh: () => Promise<void>;
|
||||
onRefreshAppSettings: () => Promise<void>;
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
}
|
||||
|
||||
type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -57,6 +61,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onHealthRefresh,
|
||||
onRefreshAppSettings,
|
||||
onLocalLabelChange,
|
||||
blockedKeys,
|
||||
blockedNames,
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -234,6 +242,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onHealthRefresh={onHealthRefresh}
|
||||
onLocalLabelChange={onLocalLabelChange}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,10 @@ export function SettingsDatabaseSection({
|
||||
onSaveAppSettings,
|
||||
onHealthRefresh,
|
||||
onLocalLabelChange,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -28,6 +32,10 @@ export function SettingsDatabaseSection({
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onHealthRefresh: () => Promise<void>;
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
@@ -280,6 +288,67 @@ export function SettingsDatabaseSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Blocked Contacts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
|
||||
affected. Messages are still stored and will reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono truncate flex-1">{key}</span>
|
||||
{onToggleBlockedKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedKey(key)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blockedNames.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedNames.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{name}</span>
|
||||
{onToggleBlockedName && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedName(name)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -62,6 +62,57 @@ export function useAppSettings() {
|
||||
[appSettings?.sidebar_sort_order]
|
||||
);
|
||||
|
||||
const handleToggleBlockedKey = useCallback(async (key: string) => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.blocked_keys ?? [];
|
||||
const wasBlocked = current.includes(normalizedKey);
|
||||
const optimistic = wasBlocked
|
||||
? current.filter((k) => k !== normalizedKey)
|
||||
: [...current, normalizedKey];
|
||||
return { ...prev, blocked_keys: optimistic };
|
||||
});
|
||||
|
||||
try {
|
||||
const updatedSettings = await api.toggleBlockedKey(key);
|
||||
setAppSettings(updatedSettings);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle blocked key:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
toast.error('Failed to update blocked key');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleBlockedName = useCallback(async (name: string) => {
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.blocked_names ?? [];
|
||||
const wasBlocked = current.includes(name);
|
||||
const optimistic = wasBlocked ? current.filter((n) => n !== name) : [...current, name];
|
||||
return { ...prev, blocked_names: optimistic };
|
||||
});
|
||||
|
||||
try {
|
||||
const updatedSettings = await api.toggleBlockedName(name);
|
||||
setAppSettings(updatedSettings);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle blocked name:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
toast.error('Failed to update blocked name');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -149,5 +200,7 @@ export function useAppSettings() {
|
||||
handleSaveAppSettings,
|
||||
handleSortOrderChange,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ const baseSettings: AppSettings = {
|
||||
community_mqtt_broker_port: 443,
|
||||
community_mqtt_email: '',
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
|
||||
@@ -229,6 +229,8 @@ export interface AppSettings {
|
||||
community_mqtt_broker_port: number;
|
||||
community_mqtt_email: string;
|
||||
flood_scope: string;
|
||||
blocked_keys: string[];
|
||||
blocked_names: string[];
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -252,6 +254,8 @@ export interface AppSettingsUpdate {
|
||||
community_mqtt_broker_port?: number;
|
||||
community_mqtt_email?: string;
|
||||
flood_scope?: string;
|
||||
blocked_keys?: string[];
|
||||
blocked_names?: string[];
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
|
||||
170
tests/test_block_lists.py
Normal file
170
tests/test_block_lists.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Tests for blocked keys and blocked names feature."""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.repository import AppSettingsRepository, MessageRepository
|
||||
from app.routers.settings import (
|
||||
BlockKeyRequest,
|
||||
BlockNameRequest,
|
||||
toggle_blocked_key,
|
||||
toggle_blocked_name,
|
||||
)
|
||||
|
||||
|
||||
class TestBlockListRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_blocked_key_adds_and_removes(self, test_db):
|
||||
result = await AppSettingsRepository.toggle_blocked_key("AABB" * 16)
|
||||
assert ("aabb" * 16) in result.blocked_keys
|
||||
|
||||
result = await AppSettingsRepository.toggle_blocked_key("AABB" * 16)
|
||||
assert ("aabb" * 16) not in result.blocked_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_key_normalization(self, test_db):
|
||||
result = await AppSettingsRepository.toggle_blocked_key("AABBccDD" * 8)
|
||||
assert ("aabbccdd" * 8) in result.blocked_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_blocked_name_adds_and_removes(self, test_db):
|
||||
result = await AppSettingsRepository.toggle_blocked_name("BadUser")
|
||||
assert "BadUser" in result.blocked_names
|
||||
|
||||
result = await AppSettingsRepository.toggle_blocked_name("BadUser")
|
||||
assert "BadUser" not in result.blocked_names
|
||||
|
||||
|
||||
class TestBlockListRouterEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_blocked_key_round_trip(self, test_db):
|
||||
key = "ff" * 32
|
||||
result = await toggle_blocked_key(BlockKeyRequest(key=key))
|
||||
assert key in result.blocked_keys
|
||||
|
||||
result = await toggle_blocked_key(BlockKeyRequest(key=key))
|
||||
assert key not in result.blocked_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_blocked_name_round_trip(self, test_db):
|
||||
result = await toggle_blocked_name(BlockNameRequest(name="Spammer"))
|
||||
assert "Spammer" in result.blocked_names
|
||||
|
||||
result = await toggle_blocked_name(BlockNameRequest(name="Spammer"))
|
||||
assert "Spammer" not in result.blocked_names
|
||||
|
||||
|
||||
class TestMessageBlockFiltering:
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _seed_messages(self, test_db):
|
||||
"""Seed messages for filtering tests."""
|
||||
now = int(time.time())
|
||||
blocked_key = "aa" * 32
|
||||
normal_key = "bb" * 32
|
||||
|
||||
# Incoming DM from blocked key
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="blocked dm",
|
||||
received_at=now,
|
||||
conversation_key=blocked_key,
|
||||
sender_timestamp=now,
|
||||
)
|
||||
|
||||
# Incoming DM from normal key
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="normal dm",
|
||||
received_at=now + 1,
|
||||
conversation_key=normal_key,
|
||||
sender_timestamp=now + 1,
|
||||
)
|
||||
|
||||
# Outgoing DM to blocked key (should NOT be filtered)
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="outgoing to blocked",
|
||||
received_at=now + 2,
|
||||
conversation_key=blocked_key,
|
||||
sender_timestamp=now + 2,
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
# Channel message from blocked name
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="BlockedName: spam message",
|
||||
received_at=now + 3,
|
||||
conversation_key="CC" * 16,
|
||||
sender_timestamp=now + 3,
|
||||
sender_name="BlockedName",
|
||||
sender_key="dd" * 32,
|
||||
)
|
||||
|
||||
# Channel message from normal sender
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="NormalUser: hello",
|
||||
received_at=now + 4,
|
||||
conversation_key="CC" * 16,
|
||||
sender_timestamp=now + 4,
|
||||
sender_name="NormalUser",
|
||||
sender_key=normal_key,
|
||||
)
|
||||
|
||||
# Channel message from blocked sender key
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="AnotherName: also blocked",
|
||||
received_at=now + 5,
|
||||
conversation_key="CC" * 16,
|
||||
sender_timestamp=now + 5,
|
||||
sender_name="AnotherName",
|
||||
sender_key=blocked_key,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_filters_blocked_key_dms(self, test_db):
|
||||
blocked_key = "aa" * 32
|
||||
msgs = await MessageRepository.get_all(blocked_keys=[blocked_key])
|
||||
texts = [m.text for m in msgs]
|
||||
assert "blocked dm" not in texts
|
||||
assert "normal dm" in texts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_never_filters_outgoing(self, test_db):
|
||||
blocked_key = "aa" * 32
|
||||
msgs = await MessageRepository.get_all(blocked_keys=[blocked_key])
|
||||
texts = [m.text for m in msgs]
|
||||
assert "outgoing to blocked" in texts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_filters_blocked_name(self, test_db):
|
||||
msgs = await MessageRepository.get_all(blocked_names=["BlockedName"])
|
||||
texts = [m.text for m in msgs]
|
||||
assert "BlockedName: spam message" not in texts
|
||||
assert "NormalUser: hello" in texts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_filters_blocked_sender_key_in_channels(self, test_db):
|
||||
blocked_key = "aa" * 32
|
||||
msgs = await MessageRepository.get_all(blocked_keys=[blocked_key])
|
||||
texts = [m.text for m in msgs]
|
||||
assert "AnotherName: also blocked" not in texts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_around_filters_blocked(self, test_db):
|
||||
# Get a normal message ID to center around
|
||||
all_msgs = await MessageRepository.get_all()
|
||||
normal_msg = next(m for m in all_msgs if m.text == "normal dm")
|
||||
|
||||
blocked_key = "aa" * 32
|
||||
msgs, _, _ = await MessageRepository.get_around(
|
||||
message_id=normal_msg.id,
|
||||
blocked_keys=[blocked_key],
|
||||
)
|
||||
texts = [m.text for m in msgs]
|
||||
assert "blocked dm" not in texts
|
||||
assert "normal dm" in texts
|
||||
assert "outgoing to blocked" in texts
|
||||
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 34 # All migrations run
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 35 # All migrations run
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify columns exist by inserting and selecting
|
||||
await conn.execute(
|
||||
@@ -183,9 +183,9 @@ class TestMigration001:
|
||||
applied1 = await run_migrations(conn)
|
||||
applied2 = await run_migrations(conn)
|
||||
|
||||
assert applied1 == 34 # All migrations run
|
||||
assert applied1 == 35 # All migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 34
|
||||
assert await get_version(conn) == 35
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -246,8 +246,8 @@ class TestMigration001:
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All migrations applied (version incremented) but no error
|
||||
assert applied == 34
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 35
|
||||
assert await get_version(conn) == 35
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -376,8 +376,8 @@ class TestMigration013:
|
||||
|
||||
# Run migration 13 (plus 14-34 which also run)
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 22
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 23
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify bots array was created with migrated data
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
@@ -497,7 +497,7 @@ class TestMigration018:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 34
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -575,8 +575,8 @@ class TestMigration018:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 17 # Migrations 18-34 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 18 # Migrations 18-35 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 35
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -648,7 +648,7 @@ class TestMigration019:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 34
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -714,8 +714,8 @@ class TestMigration020:
|
||||
assert (await cursor.fetchone())[0] == "delete"
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 15 # Migrations 20-34
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 16 # Migrations 20-35
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify WAL mode
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -745,7 +745,7 @@ class TestMigration020:
|
||||
await set_version(conn, 20)
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 14 # Migrations 21-34 still run
|
||||
assert applied == 15 # Migrations 21-35 still run
|
||||
|
||||
# Still WAL + INCREMENTAL
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -803,8 +803,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify payload_hash column is now BLOB
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
@@ -873,8 +873,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 7 # Version still bumped
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 8 # Version still bumped
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify data unchanged
|
||||
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
|
||||
@@ -923,8 +923,8 @@ class TestMigration032:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify all columns exist with correct defaults
|
||||
cursor = await conn.execute(
|
||||
@@ -996,8 +996,8 @@ class TestMigration034:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
# Verify column exists with correct default
|
||||
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
|
||||
@@ -1039,8 +1039,8 @@ class TestMigration033:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 34
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 35
|
||||
|
||||
cursor = await conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
|
||||
|
||||
@@ -508,6 +508,8 @@ class TestAppSettingsRepository:
|
||||
"community_mqtt_broker_port": 443,
|
||||
"community_mqtt_email": "",
|
||||
"flood_scope": "",
|
||||
"blocked_keys": "[]",
|
||||
"blocked_names": "[]",
|
||||
}
|
||||
)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
Reference in New Issue
Block a user