返回 douyin-downloader
test_database_top_authors.py
根目录 / tests / test_database_top_authors.py
1 # Feature: desktop-workflow-polish, Property D: Top_Authors_Endpoint invariants
2 """Property-based test for ``Database.get_top_authors``.
3
4 **Validates Property D: Top_Authors_Endpoint invariants**
5
6 **Validates: Requirements 5.2, 5.3, 5.4, 5.5, 5.12**
7
8 For any random population of ``aweme`` rows (where ``author_sec_uid`` may be
9 ``None`` / ``""`` / non-empty, ``author_name`` may be ``None`` / ``""`` /
10 non-empty, and ``create_time`` / ``download_time`` are arbitrary unix seconds)
11 and any ``(days, limit)`` with ``1 <= days <= 365`` and ``1 <= limit <= 20``,
12 ``get_top_authors(days=days, limit=limit)`` must satisfy:
13
14 1. ``len(result) <= limit``
15 2. Every ``a.sec_uid`` is non-empty and not ``None``
16 3. Every ``a.download_count >= 1``
17 4. Sorted by ``(-a.download_count, a.sec_uid)`` (stable tie-break)
18 5. All ``sec_uid`` values in the result are unique
19 6. Each ``sec_uid`` in the result has at least one row with
20 ``create_time >= now - days*86400`` in the original data
21 7. Each ``a.author_name`` is either the latest non-empty ``author_name`` for
22 that ``sec_uid`` (by ``download_time DESC``) or ``"未知作者"`` when no
23 non-empty name exists for that ``sec_uid``.
24
25 The test also serves as regression coverage for the stable ordering required
26 by the design doc (Property D explicitly requires stable sort to avoid flaky
27 property tests on ties).
28 """
29
30 from __future__ import annotations
31
32 import asyncio
33 import os
34 import tempfile
35 from datetime import datetime
36 from typing import Any, Dict, List, Optional
37
38 from hypothesis import HealthCheck, given
39 from hypothesis import settings as hyp_settings
40 from hypothesis import strategies as st
41
42 from storage.database import Database
43
44 # ---------------------------------------------------------------------------
45 # Hypothesis strategies
46 # ---------------------------------------------------------------------------
47
48 # Small, reusable pool for ``author_sec_uid``. Keeping the pool tiny makes
49 # grouping interesting (we actually get rows that share a sec_uid) instead of
50 # generating mostly-unique strings that degenerate into groups of size 1.
51 # Empty / null values exercise the "must be filtered out" branch (R5.4).
52 _SEC_UID_POOL = st.sampled_from(["", None, "uid_a", "uid_b", "uid_c", "uid_d", "uid_e"])
53
54 # Tiny pool for ``author_name``. Allow empty string + None to exercise the
55 # fallback path to ``"未知作者"`` (R5.5).
56 _AUTHOR_NAME_POOL = st.one_of(
57 st.none(),
58 st.just(""),
59 st.sampled_from(["Alice", "Bob", "Charlie", "Diana"]),
60 )
61
62 # ``create_time_offset_seconds`` lets us place rows inside or outside any
63 # cutoff window. Range covers roughly [-400 days, +30 days]: plenty of rows
64 # land inside the in-window region and plenty land outside it for any
65 # ``days ∈ [1, 365]``.
66 _CREATE_OFFSET = st.integers(min_value=-400 * 86400, max_value=30 * 86400)
67
68 # ``download_time_offset_seconds`` is a non-negative offset into the past.
69 # Distinct offsets ⇒ distinct ``download_time`` values for most rows, which
70 # lets us meaningfully assert "latest non-empty author_name is selected".
71 _DOWNLOAD_OFFSET = st.integers(min_value=0, max_value=365 * 86400)
72
73 _aweme_row_strategy = st.fixed_dictionaries(
74 {
75 "author_sec_uid": _SEC_UID_POOL,
76 "author_name": _AUTHOR_NAME_POOL,
77 "create_time_offset_seconds": _CREATE_OFFSET,
78 "download_time_offset_seconds": _DOWNLOAD_OFFSET,
79 }
80 )
81
82
83 # ---------------------------------------------------------------------------
84 # Async helpers
85 # ---------------------------------------------------------------------------
86
87
88 async def _populate_and_query(
89 rows: List[Dict[str, Any]],
90 *,
91 days: int,
92 limit: int,
93 ) -> Dict[str, Any]:
94 """Insert generated rows into a fresh DB and call ``get_top_authors``.
95
96 Returns a dict containing the query ``result`` plus the ``now_before`` /
97 ``now_after`` timestamps straddling the query call so the caller can
98 reason about the method's wall-clock cutoff without being flaky on the
99 second boundary.
100 """
101 with tempfile.TemporaryDirectory() as td:
102 db_path = os.path.join(td, "test.db")
103 db = Database(db_path=db_path)
104 try:
105 await db.initialize()
106 # We use the private conn here so we can insert with an explicit
107 # ``download_time`` value. ``add_aweme`` / ``add_aweme_batch``
108 # both hard-code ``datetime.now()`` which would collapse all
109 # download_times to the same value, making the "latest
110 # author_name" assertion trivially satisfied.
111 conn = await db._get_conn()
112 now_ref = int(datetime.now().timestamp())
113 # ``now_ref`` is the "test time origin" we use to translate
114 # per-row offsets into absolute timestamps. The DB method uses
115 # ``datetime.now()`` internally; we capture ``now_before`` right
116 # before the call so we can bound the method's cutoff.
117 for idx, row in enumerate(rows):
118 create_time = now_ref + row["create_time_offset_seconds"]
119 download_time = now_ref - row["download_time_offset_seconds"]
120 await conn.execute(
121 """
122 INSERT INTO aweme (
123 aweme_id, aweme_type, title, author_id,
124 author_name, author_sec_uid, create_time,
125 download_time, file_path, metadata
126 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
127 """,
128 (
129 f"id_{idx}",
130 "video",
131 None,
132 None,
133 row["author_name"],
134 row["author_sec_uid"],
135 create_time,
136 download_time,
137 None,
138 None,
139 ),
140 )
141 await conn.commit()
142
143 now_before = int(datetime.now().timestamp())
144 result = await db.get_top_authors(days=days, limit=limit)
145 now_after = int(datetime.now().timestamp())
146 return {
147 "result": result,
148 "rows": rows,
149 "now_ref": now_ref,
150 "now_before": now_before,
151 "now_after": now_after,
152 }
153 finally:
154 await db.close()
155
156
157 # ---------------------------------------------------------------------------
158 # Assertion helpers
159 # ---------------------------------------------------------------------------
160
161
162 def _absolute_create_time(row: Dict[str, Any], now_ref: int) -> int:
163 return now_ref + int(row["create_time_offset_seconds"])
164
165
166 def _absolute_download_time(row: Dict[str, Any], now_ref: int) -> int:
167 return now_ref - int(row["download_time_offset_seconds"])
168
169
170 def _latest_nonempty_names_for(
171 sec_uid: str,
172 rows: List[Dict[str, Any]],
173 now_ref: int,
174 ) -> Optional[set]:
175 """Return the set of ``author_name`` values tied for the max download_time
176 among rows whose ``author_sec_uid == sec_uid`` and whose ``author_name``
177 is non-empty / non-null. Returns ``None`` if no such row exists.
178
179 We return a set (rather than a single value) because SQLite's
180 ``ORDER BY ... LIMIT 1`` is not deterministic on ties.
181 """
182 candidates = [
183 (_absolute_download_time(r, now_ref), r["author_name"])
184 for r in rows
185 if r["author_sec_uid"] == sec_uid
186 and r["author_name"] is not None
187 and r["author_name"] != ""
188 ]
189 if not candidates:
190 return None
191 max_dt = max(dt for dt, _ in candidates)
192 return {name for dt, name in candidates if dt == max_dt}
193
194
195 def _assert_invariants(ctx: Dict[str, Any], *, days: int, limit: int) -> None:
196 result: List[Dict[str, Any]] = ctx["result"]
197 rows: List[Dict[str, Any]] = ctx["rows"]
198 now_ref: int = ctx["now_ref"]
199 now_before: int = ctx["now_before"]
200
201 # Invariant 1: length bounded by limit.
202 assert len(result) <= limit, f"result length {len(result)} exceeds limit {limit}"
203
204 # Invariant 5: all sec_uid values in result are unique.
205 sec_uids = [a["sec_uid"] for a in result]
206 assert len(set(sec_uids)) == len(sec_uids), f"duplicate sec_uid in result: {sec_uids}"
207
208 # Invariant 4: sorted by (-download_count, sec_uid).
209 sort_keys = [(-a["download_count"], a["sec_uid"]) for a in result]
210 assert sort_keys == sorted(sort_keys), (
211 f"result is not sorted by (-download_count, sec_uid): {sort_keys}"
212 )
213
214 # Bound the db method's internal cutoff. The method uses
215 # ``datetime.now()`` once inside; that ``now`` is in
216 # ``[now_before, now_after]``. Therefore ``cutoff_db`` is in
217 # ``[now_before - days*86400, now_after - days*86400]``.
218 # A necessary condition for a row to have been included is
219 # ``create_time >= cutoff_db``, which implies
220 # ``create_time >= now_before - days*86400`` (because cutoff_db is at
221 # least that value).
222 necessary_cutoff_lower_bound = now_before - days * 86400
223
224 for a in result:
225 # Invariant 2: sec_uid must be non-empty / non-null.
226 assert a["sec_uid"] is not None, "sec_uid is None in result"
227 assert a["sec_uid"] != "", "sec_uid is empty string in result"
228
229 # Invariant 3: download_count >= 1.
230 assert a["download_count"] >= 1, (
231 f"download_count {a['download_count']} < 1 for sec_uid {a['sec_uid']}"
232 )
233
234 sec_uid = a["sec_uid"]
235 matching_rows = [r for r in rows if r["author_sec_uid"] == sec_uid]
236
237 # Invariant 6: at least one row for this sec_uid has
238 # ``create_time >= cutoff_db``, which is necessarily
239 # ``>= necessary_cutoff_lower_bound``.
240 in_window_rows = [
241 r
242 for r in matching_rows
243 if _absolute_create_time(r, now_ref) >= necessary_cutoff_lower_bound
244 ]
245 assert in_window_rows, (
246 f"sec_uid {sec_uid!r} appeared in result but has no row with "
247 f"create_time >= {necessary_cutoff_lower_bound} "
248 f"(now_before={now_before}, days={days})"
249 )
250
251 # Invariant 7: author_name is either the latest non-empty name for
252 # this sec_uid (ties allowed) or the placeholder "未知作者".
253 latest_names = _latest_nonempty_names_for(sec_uid, rows, now_ref)
254 if latest_names is None:
255 assert a["author_name"] == "未知作者", (
256 f"sec_uid {sec_uid!r} has no non-empty author_name rows, "
257 f"expected '未知作者' but got {a['author_name']!r}"
258 )
259 else:
260 assert a["author_name"] in latest_names, (
261 f"sec_uid {sec_uid!r} author_name {a['author_name']!r} not "
262 f"in tied latest set {latest_names!r}"
263 )
264
265
266 # ---------------------------------------------------------------------------
267 # Property test
268 # ---------------------------------------------------------------------------
269
270
271 @given(
272 rows=st.lists(_aweme_row_strategy, max_size=200),
273 days=st.integers(min_value=1, max_value=365),
274 limit=st.integers(min_value=1, max_value=20),
275 )
276 @hyp_settings(
277 deadline=None,
278 max_examples=100,
279 # ``tempfile.TemporaryDirectory`` + async roundtrip is a little slow and
280 # may trigger ``too_slow`` on busy CI nodes; we opt out since 100
281 # iterations is the explicit contract from the task list.
282 suppress_health_check=[
283 HealthCheck.function_scoped_fixture,
284 HealthCheck.too_slow,
285 ],
286 )
287 def test_top_authors_invariants(rows, days, limit):
288 """Property D — Top_Authors_Endpoint invariants."""
289 ctx = asyncio.run(_populate_and_query(rows, days=days, limit=limit))
290 _assert_invariants(ctx, days=days, limit=limit)
291
291 lines PYTHON