mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
A channel used to have two settings: every message or nothing. Now it has three - muted, every message, or a named profile of rules (mentions me, message contains, sender contains; any or all must match). A channel on a profile counts only matching messages: the unread badge, the navbar bell, the app-icon badge and the browser/Android notification all follow the same rule. Direct messages are untouched. - app/notify_profiles.py: rule matching (case- and diacritic-insensitive, same table as the chat filter), validation, storage in app_settings - read_status.notify_profile column, written together with is_muted so a mute never leaves a profile behind; migration added - /api/notification-profiles CRUD, PUT /api/channels/<i>/notifications; /api/read_status and /api/messages/updates report channel_notify_profiles, the latter counts a profiled channel's unread through the profile - Manage Channels: the bell opens a menu (Off / Every message / profiles / Manage profiles...) instead of toggling; profiled rows get a green badge - Settings > Notifications: profile list with in-use counts and an editor - app.js evaluates the same rules for socket-delivered messages, and a state version guard stops an in-flight poll from undoing a fresh mute/favorite/profile change - i18n en/pl (43 keys), docs (user guide, architecture, whatsnew), tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
205 lines
7.8 KiB
Python
205 lines
7.8 KiB
Python
"""
|
|
Tests for notification profiles: rule matching, validation and the
|
|
per-channel notification state in read_status.
|
|
|
|
Run: python -m pytest tests/test_notify_profiles.py -v
|
|
"""
|
|
|
|
import sqlite3
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.database import Database
|
|
from app.notify_profiles import (
|
|
MAX_NAME_LEN,
|
|
MAX_RULES,
|
|
MAX_VALUE_LEN,
|
|
normalize,
|
|
profile_matches,
|
|
rule_matches,
|
|
validate_profile,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
yield Database(Path(tmp) / 'test.db')
|
|
|
|
|
|
# ================================================================
|
|
# Normalization - must agree with normalizeText() in filter-utils.js
|
|
# ================================================================
|
|
|
|
class TestNormalize:
|
|
def test_lowercases(self):
|
|
assert normalize('MarWoj') == 'marwoj'
|
|
|
|
def test_folds_polish_diacritics(self):
|
|
assert normalize('Kraków Łódź Żółć') == 'krakow lodz zolc'
|
|
|
|
def test_folds_sharp_s(self):
|
|
assert normalize('Straße') == 'strasse'
|
|
|
|
def test_empty_and_none(self):
|
|
assert normalize('') == ''
|
|
assert normalize(None) == ''
|
|
|
|
|
|
# ================================================================
|
|
# Rules
|
|
# ================================================================
|
|
|
|
class TestRuleMatches:
|
|
def test_mention_plain_name(self):
|
|
assert rule_matches({'type': 'mention'}, 'Kosu', 'hej marwoj, jesteś?', 'MarWoj')
|
|
|
|
def test_mention_bracket_syntax(self):
|
|
assert rule_matches({'type': 'mention'}, 'Kosu', '@[MarWoj] ping', 'MarWoj')
|
|
|
|
def test_mention_needs_own_name(self):
|
|
assert not rule_matches({'type': 'mention'}, 'Kosu', 'marwoj', '')
|
|
|
|
def test_mention_absent(self):
|
|
assert not rule_matches({'type': 'mention'}, 'Kosu', 'hello world', 'MarWoj')
|
|
|
|
def test_text_substring_case_and_diacritics(self):
|
|
assert rule_matches({'type': 'text', 'value': 'krakow'}, 'x', 'Jadę do Krakowa', '')
|
|
|
|
def test_text_part_of_word(self):
|
|
assert rule_matches({'type': 'text', 'value': 'webui'}, 'x', 'mc-webui rocks', '')
|
|
|
|
def test_text_no_match(self):
|
|
assert not rule_matches({'type': 'text', 'value': 'webui'}, 'x', 'nothing here', '')
|
|
|
|
def test_text_empty_value_never_matches(self):
|
|
assert not rule_matches({'type': 'text', 'value': ''}, 'x', 'anything', '')
|
|
|
|
def test_sender_matches_sender_not_content(self):
|
|
assert rule_matches({'type': 'sender', 'value': 'kosu'}, 'Kosu-R', 'hello', '')
|
|
assert not rule_matches({'type': 'sender', 'value': 'kosu'}, 'Other', 'kosu wrote', '')
|
|
|
|
def test_unknown_type(self):
|
|
assert not rule_matches({'type': 'regex', 'value': '.*'}, 'x', 'anything', '')
|
|
|
|
|
|
class TestProfileMatches:
|
|
PROFILE = {
|
|
'rules': [{'type': 'mention'}, {'type': 'text', 'value': 'webui'}],
|
|
}
|
|
|
|
def test_any_needs_one(self):
|
|
p = {**self.PROFILE, 'match': 'any'}
|
|
assert profile_matches(p, 'x', 'about webui', 'MarWoj')
|
|
assert profile_matches(p, 'x', 'hey MarWoj', 'MarWoj')
|
|
assert not profile_matches(p, 'x', 'plain text', 'MarWoj')
|
|
|
|
def test_all_needs_every(self):
|
|
p = {**self.PROFILE, 'match': 'all'}
|
|
assert profile_matches(p, 'x', 'MarWoj, webui broke', 'MarWoj')
|
|
assert not profile_matches(p, 'x', 'about webui', 'MarWoj')
|
|
|
|
def test_missing_match_means_any(self):
|
|
assert profile_matches(self.PROFILE, 'x', 'about webui', '')
|
|
|
|
def test_no_rules_matches_nothing(self):
|
|
assert not profile_matches({'match': 'all', 'rules': []}, 'x', 'anything', 'me')
|
|
assert not profile_matches({'match': 'any'}, 'x', 'anything', 'me')
|
|
|
|
|
|
# ================================================================
|
|
# Validation
|
|
# ================================================================
|
|
|
|
class TestValidateProfile:
|
|
def test_cleans_and_keeps_rules(self):
|
|
clean, err = validate_profile({
|
|
'name': ' Mentions ',
|
|
'match': 'all',
|
|
'rules': [{'type': 'mention', 'value': 'ignored'}, {'type': 'text', 'value': ' webui '}],
|
|
})
|
|
assert err is None
|
|
assert clean == {
|
|
'name': 'Mentions',
|
|
'match': 'all',
|
|
'rules': [{'type': 'mention'}, {'type': 'text', 'value': 'webui'}],
|
|
}
|
|
|
|
def test_default_match_is_any(self):
|
|
clean, _ = validate_profile({'name': 'x', 'rules': [{'type': 'mention'}]})
|
|
assert clean['match'] == 'any'
|
|
|
|
@pytest.mark.parametrize('payload, fragment', [
|
|
('not a dict', 'object'),
|
|
({'name': '', 'rules': [{'type': 'mention'}]}, 'Name is required'),
|
|
({'name': 'x' * (MAX_NAME_LEN + 1), 'rules': [{'type': 'mention'}]}, 'too long'),
|
|
({'name': 'x', 'match': 'some', 'rules': [{'type': 'mention'}]}, 'Match'),
|
|
({'name': 'x', 'rules': []}, 'At least one rule'),
|
|
({'name': 'x', 'rules': 'mention'}, 'At least one rule'),
|
|
({'name': 'x', 'rules': [{'type': 'mention'}] * (MAX_RULES + 1)}, 'Too many'),
|
|
({'name': 'x', 'rules': ['mention']}, 'must be an object'),
|
|
({'name': 'x', 'rules': [{'type': 'regex', 'value': 'a'}]}, 'Unknown rule type'),
|
|
({'name': 'x', 'rules': [{'type': 'text', 'value': ' '}]}, 'cannot be empty'),
|
|
({'name': 'x', 'rules': [{'type': 'sender', 'value': 'y' * (MAX_VALUE_LEN + 1)}]}, 'too long'),
|
|
])
|
|
def test_rejects(self, payload, fragment):
|
|
clean, err = validate_profile(payload)
|
|
assert clean is None
|
|
assert fragment in err
|
|
|
|
|
|
# ================================================================
|
|
# Per-channel state in read_status
|
|
# ================================================================
|
|
|
|
class TestChannelNotifyState:
|
|
def test_migration_adds_column(self, db):
|
|
with sqlite3.connect(db.db_path) as conn:
|
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(read_status)")}
|
|
assert 'notify_profile' in cols
|
|
|
|
def test_profile_mode(self, db):
|
|
db.set_channel_notify(3, muted=False, profile_id='abcd1234')
|
|
assert db.get_channel_notify_profiles() == {3: 'abcd1234'}
|
|
assert db.get_muted_channels() == []
|
|
|
|
def test_mute_clears_profile(self, db):
|
|
db.set_channel_notify(3, muted=False, profile_id='abcd1234')
|
|
db.set_channel_notify(3, muted=True)
|
|
assert db.get_channel_notify_profiles() == {}
|
|
assert db.get_muted_channels() == [3]
|
|
|
|
def test_muted_with_profile_stores_no_profile(self, db):
|
|
db.set_channel_notify(3, muted=True, profile_id='abcd1234')
|
|
assert db.get_channel_notify_profiles() == {}
|
|
|
|
def test_all_mode_clears_both(self, db):
|
|
db.set_channel_notify(3, muted=False, profile_id='abcd1234')
|
|
db.set_channel_notify(3, muted=False, profile_id=None)
|
|
assert db.get_channel_notify_profiles() == {}
|
|
assert db.get_muted_channels() == []
|
|
|
|
def test_legacy_set_channel_muted_unmute_lands_on_all(self, db):
|
|
db.set_channel_notify(3, muted=False, profile_id='abcd1234')
|
|
db.set_channel_muted(3, False)
|
|
assert db.get_channel_notify_profiles() == {}
|
|
|
|
def test_keeps_last_seen_and_favorite(self, db):
|
|
db.mark_read('chan_3', 1000)
|
|
db.set_channel_favorite(3, True)
|
|
db.set_channel_notify(3, muted=False, profile_id='abcd1234')
|
|
row = db.get_read_status()['chan_3']
|
|
assert row['last_seen_ts'] == 1000
|
|
assert row['is_favorite'] == 1
|
|
assert row['notify_profile'] == 'abcd1234'
|
|
|
|
def test_clear_profile_returns_affected_channels(self, db):
|
|
db.set_channel_notify(1, muted=False, profile_id='p1')
|
|
db.set_channel_notify(2, muted=False, profile_id='p2')
|
|
db.set_channel_notify(5, muted=False, profile_id='p1')
|
|
assert sorted(db.clear_channel_notify_profile('p1')) == [1, 5]
|
|
assert db.get_channel_notify_profiles() == {2: 'p2'}
|
|
assert db.clear_channel_notify_profile('p1') == []
|