| 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 |