summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_tmdb_search_bound.py
blob: 3153b1530dd8ba21c205d60a417e4b8455e3fa2a (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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""
One member's typing must not spend what the whole group depends on.

`tmdb_search_req` takes a member's free text and calls TMDB with the
**operator's** credential. That credential is rated by TMDB and shared: the
automatic matching every other member sees runs on it too. So a member holding
down a search box — or a script doing it — degrades the library for everyone and
costs the operator their quota, and the node had no ceiling of any kind on it.
§6.5's standing rule is a bound and a named adversary in the same commit; this
handler shipped with neither.

Two members in every test here, which is the point: a ceiling that one person
can exhaust for another is not a ceiling, it is a queue. The per-member window
is what keeps them apart, and the node-wide one is what keeps them together
from emptying the operator's quota — they answer different questions and both
are checked.

The refusal is an error rather than an empty result. An empty list is what "no
such film" looks like, and telling somebody their film is unknown when the node
simply declined to ask is a worse answer than the truth.
"""

import pytest
from meshbay_node.transport.webrtc.apps import video_meta
from meshbay_node.transport.webrtc_server import WebRTCPeerSession

GROUP = "g" * 32


class _FakeTmdb:
    """Counts what would have been spent."""

    def __init__(self):
        self.calls = 0

    async def search_movie_results(self, query):
        self.calls += 1
        return [{"id": 1, "title": "Some Saga", "release_date": "1999-01-01",
                 "poster_path": None}]

    async def search_tv_results(self, query):
        self.calls += 1
        return []


class _FakeMediaCache:
    async def get_thumb_hash_by_file_id(self, _file_id):
        return None


@pytest.fixture
def group():
    """One group's context, shared by every session in it, as a node has."""
    return {
        "gek": b"k" * 32,
        "tmdb_enabled": True,
    }


@pytest.fixture
def node(group):
    tmdb = _FakeTmdb()
    ctx = {
        "groups": {GROUP: group},
        "media_cache": _FakeMediaCache(),
        "tmdb_client": tmdb,
    }
    return ctx, tmdb


def _member(ctx, user_id: str) -> WebRTCPeerSession:
    s = WebRTCPeerSession.__new__(WebRTCPeerSession)
    s._ctx = ctx
    s._group_id = GROUP
    s._user_id = user_id
    s._peer_id = user_id
    s.sent = []
    s._send = s.sent.append
    s._audit = lambda *a, **k: None
    return s


async def _search(session, query="a film"):
    await session._do_tmdb_search_request(
        {"query": query, "media_type": "movie"})


def _refusals(session):
    return [m for m in session.sent
            if m.get("code") == "tmdb_search_rate_limited"]


async def test_a_member_at_the_ceiling_does_not_stop_another_one(node, monkeypatch):
    """
    The property a one-member test cannot state.

    Alice exhausts her own window; Bob, who has typed nothing, must be served
    exactly as if she had not been there.
    """
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_PER_MEMBER", 3)
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_NODE", 100)
    ctx, tmdb = node

    alice = _member(ctx, "alice")
    for i in range(4):
        await _search(alice, f"film {i}")
    assert tmdb.calls == 3, "the ceiling did not stop the fourth search"
    assert len(_refusals(alice)) == 1

    bob = _member(ctx, "bob")
    await _search(bob, "something else")
    assert tmdb.calls == 4
    assert _refusals(bob) == []


async def test_one_member_cannot_spend_the_whole_node_quota(node, monkeypatch):
    """
    And the other half: two members together still meet a node-wide ceiling,
    because the operator's credential is one credential however many people
    hold the search box down.
    """
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_PER_MEMBER", 100)
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_NODE", 2)
    ctx, tmdb = node

    alice, bob = _member(ctx, "alice"), _member(ctx, "bob")
    await _search(alice)
    await _search(bob)
    await _search(bob)

    assert tmdb.calls == 2
    assert len(_refusals(bob)) == 1


async def test_a_members_count_survives_their_reconnection(node, monkeypatch):
    """
    Kept in the group context, not on the session: otherwise the ceiling is one
    reconnect wide, and a client that drops its DataChannel between searches has
    no ceiling at all.
    """
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_PER_MEMBER", 2)
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_NODE", 100)
    ctx, tmdb = node

    first = _member(ctx, "alice")
    await _search(first, "one")
    await _search(first, "two")

    reconnected = _member(ctx, "alice")     # same person, new connection
    await _search(reconnected, "three")

    assert tmdb.calls == 2, "a reconnect reset the member's window"
    assert len(_refusals(reconnected)) == 1


async def test_a_refusal_is_said_out_loud_and_not_drawn_as_no_matches(node, monkeypatch):
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_PER_MEMBER", 0)
    ctx, _ = node

    alice = _member(ctx, "alice")
    await _search(alice)

    (msg,) = alice.sent
    assert msg["type"] == "error"
    assert msg["code"] == "tmdb_search_rate_limited"
    assert msg.get("results") is None, (
        "a refusal that carries an empty result list reads as 'no such film'")


async def test_the_windows_do_not_grow_without_bound(node, monkeypatch):
    """
    The lists are trimmed on every call, so the thing that bounds a member also
    bounds what remembering them costs.
    """
    monkeypatch.setattr(video_meta, "_TMDB_SEARCH_WINDOW", 0.0)
    ctx, tmdb = node

    alice = _member(ctx, "alice")
    for i in range(12):
        await _search(alice, f"film {i}")

    # Every entry ages out before the next call, so nothing is refused, and what
    # is kept is the one just recorded rather than one per search ever made.
    assert tmdb.calls == 12
    assert len(ctx["groups"][GROUP]["tmdb_search_hits"]["alice"]) == 1
    assert len(ctx["tmdb_search_hits_node"]) == 1