aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_chat_pagination.py
blob: f3949a9a3674afcb5dca5c2a3d21082527236fa8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"""
Reading a chat backwards.

`get_messages(since, limit)` pages *forward* from the oldest message, which is
the right shape for "what happened since I last looked" and the wrong shape for
opening a conversation. The browser asked it for `since=0, limit=200`, so a
group with more than 200 messages showed its first 200 and never the exchange
the reader came for. These tests pin the direction, because the bug was not a
crash — it was a plausible-looking screen full of the wrong messages.

The cursor is the row id rather than the timestamp. `timestamp` is a float from
`time.time()`, so two messages saved in the same tick can share one, and a
timestamp cursor would then skip a message or return it twice.
"""

from pathlib import Path

import pytest
import pytest_asyncio
from meshbay_node.chat.store import ChatStore


@pytest_asyncio.fixture
async def store(tmp_path: Path):
    async with ChatStore(tmp_path / "chat.db") as s:
        yield s


async def _fill(store, n: int) -> None:
    for i in range(n):
        await store.save_message(sender_id="u1", iteration=i,
                                 payload=f"msg-{i:03d}".encode())


def _texts(msgs) -> list[str]:
    return [m.payload.decode() for m in msgs]


@pytest.mark.asyncio
async def test_recent_returns_the_newest_not_the_oldest(store):
    await _fill(store, 300)
    got = _texts(await store.get_recent(limit=100))

    assert len(got) == 100
    assert got[-1] == "msg-299", "the newest message must be in the answer"
    assert got[0] == "msg-200"


@pytest.mark.asyncio
async def test_recent_is_ordered_oldest_first_for_rendering(store):
    await _fill(store, 10)
    got = _texts(await store.get_recent(limit=5))
    assert got == ["msg-005", "msg-006", "msg-007", "msg-008", "msg-009"]


@pytest.mark.asyncio
async def test_recent_handles_fewer_messages_than_the_limit(store):
    await _fill(store, 3)
    assert _texts(await store.get_recent(limit=100)) == \
        ["msg-000", "msg-001", "msg-002"]


@pytest.mark.asyncio
async def test_recent_on_an_empty_group(store):
    assert await store.get_recent(limit=100) == []


@pytest.mark.asyncio
async def test_before_walks_backwards_without_gap_or_repeat(store):
    """Paging to the start must show every message exactly once."""
    await _fill(store, 250)

    page = await store.get_recent(limit=100)
    seen = _texts(page)
    while await store.has_before(page[0].id):
        page = await store.get_before(page[0].id, limit=50)
        assert page, "has_before said there was more"
        seen = _texts(page) + seen

    assert seen == [f"msg-{i:03d}" for i in range(250)]
    assert len(seen) == len(set(seen)), "no message returned twice"


@pytest.mark.asyncio
async def test_before_is_ordered_oldest_first(store):
    await _fill(store, 20)
    recent = await store.get_recent(limit=5)
    older = _texts(await store.get_before(recent[0].id, limit=5))
    assert older == ["msg-010", "msg-011", "msg-012", "msg-013", "msg-014"]


@pytest.mark.asyncio
async def test_before_excludes_the_cursor_message(store):
    await _fill(store, 10)
    recent = await store.get_recent(limit=3)
    older = await store.get_before(recent[0].id, limit=10)
    assert recent[0].id not in [m.id for m in older]


@pytest.mark.asyncio
async def test_has_before_is_false_at_the_start_of_history(store):
    await _fill(store, 5)
    page = await store.get_recent(limit=100)
    assert await store.has_before(page[0].id) is False


@pytest.mark.asyncio
async def test_has_before_is_true_when_older_messages_exist(store):
    await _fill(store, 200)
    page = await store.get_recent(limit=100)
    assert await store.has_before(page[0].id) is True


@pytest.mark.asyncio
async def test_messages_sharing_a_timestamp_are_still_paged_exactly_once(store):
    """The reason the cursor is the row id rather than the timestamp.

    Nothing makes `timestamp` unique — it is a float from `time.time()`. In
    practice `save_message` commits per row and they rarely tie, so the tie is
    forced here instead of hoped for: what matters is that a shared timestamp
    cannot make a page skip a message or repeat one, not how often it happens.
    """
    for i in range(40):
        await store._db.execute(
            "INSERT INTO messages (sender_id, iteration, payload, timestamp, "
            "thread_id, sender_name) VALUES (?, ?, ?, ?, ?, ?)",
            ("u1", i, f"msg-{i:03d}".encode(), 1000.0, None, ""))
    await store._db.commit()

    rows = await store.get_recent(limit=40)
    assert len({m.timestamp for m in rows}) == 1, "the tie should be in place"

    page = await store.get_recent(limit=10)
    seen = _texts(page)
    while await store.has_before(page[0].id):
        page = await store.get_before(page[0].id, limit=10)
        seen = _texts(page) + seen
    assert seen == [f"msg-{i:03d}" for i in range(40)]


@pytest.mark.asyncio
async def test_forward_paging_still_works_for_callers_that_want_it(store):
    """`get_messages` keeps its meaning — the node UI reads "since" from it."""
    await _fill(store, 10)
    all_msgs = await store.get_messages(limit=100)
    cutoff = all_msgs[4].timestamp
    after = await store.get_messages(since=cutoff, limit=100)
    assert all(m.timestamp > cutoff for m in after)