···11+CREATE TABLE IF NOT EXISTS actors (
22+ did TEXT PRIMARY KEY,
33+ handle TEXT NOT NULL,
44+ display_name TEXT DEFAULT '',
55+ avatar_cid TEXT DEFAULT '',
66+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
77+);
88+99+CREATE INDEX IF NOT EXISTS idx_actors_handle ON actors(handle COLLATE NOCASE);
1010+1111+CREATE VIRTUAL TABLE IF NOT EXISTS actors_fts USING fts5(
1212+ handle, display_name,
1313+ content='actors', content_rowid='rowid',
1414+ tokenize='unicode61 remove_diacritics 2'
1515+);
1616+1717+-- keep FTS5 in sync via triggers
1818+CREATE TRIGGER IF NOT EXISTS actors_ai AFTER INSERT ON actors BEGIN
1919+ INSERT INTO actors_fts(rowid, handle, display_name)
2020+ VALUES (new.rowid, new.handle, new.display_name);
2121+END;
2222+2323+CREATE TRIGGER IF NOT EXISTS actors_ad AFTER DELETE ON actors BEGIN
2424+ INSERT INTO actors_fts(actors_fts, rowid, handle, display_name)
2525+ VALUES ('delete', old.rowid, old.handle, old.display_name);
2626+END;
2727+2828+CREATE TRIGGER IF NOT EXISTS actors_au AFTER UPDATE ON actors BEGIN
2929+ INSERT INTO actors_fts(actors_fts, rowid, handle, display_name)
3030+ VALUES ('delete', old.rowid, old.handle, old.display_name);
3131+ INSERT INTO actors_fts(rowid, handle, display_name)
3232+ VALUES (new.rowid, new.handle, new.display_name);
3333+END;
+204
scripts/smoke.py
···11+#!/usr/bin/env -S PYTHONUNBUFFERED=1 uv run --script --quiet
22+# /// script
33+# requires-python = ">=3.12"
44+# dependencies = []
55+# ///
66+"""
77+smoke tests for typeahead service.
88+99+verifies response shape, search behavior, CORS, and optional
1010+comparison against public.api.bsky.app.
1111+1212+usage:
1313+ ./scripts/smoke.py --url https://typeahead.waow.tech
1414+ ./scripts/smoke.py --url http://localhost:8787
1515+ ./scripts/smoke.py --url https://typeahead.waow.tech --compare
1616+"""
1717+1818+import argparse
1919+import json
2020+import sys
2121+import urllib.request
2222+import urllib.error
2323+2424+PASS = "\033[32mpass\033[0m"
2525+FAIL = "\033[31mFAIL\033[0m"
2626+SKIP = "\033[33mskip\033[0m"
2727+2828+failures = 0
2929+3030+BSKY_PUBLIC = "https://public.api.bsky.app"
3131+XRPC_PATH = "/xrpc/app.bsky.actor.searchActorsTypeahead"
3232+3333+3434+def check(name: str, ok: bool, detail: str = ""):
3535+ global failures
3636+ tag = PASS if ok else FAIL
3737+ msg = f" [{tag}] {name}"
3838+ if detail:
3939+ msg += f" ({detail})"
4040+ print(msg)
4141+ if not ok:
4242+ failures += 1
4343+ return ok
4444+4545+4646+def fetch(url: str, timeout: int = 15) -> tuple[dict | None, dict]:
4747+ """fetch JSON + response headers. returns (body, headers)."""
4848+ try:
4949+ req = urllib.request.Request(url, headers={"User-Agent": "typeahead-smoke/1.0"})
5050+ with urllib.request.urlopen(req, timeout=timeout) as resp:
5151+ headers = {k.lower(): v for k, v in resp.headers.items()}
5252+ return json.loads(resp.read()), headers
5353+ except urllib.error.HTTPError as e:
5454+ return {"_http_error": e.code}, {}
5555+ except Exception as e:
5656+ return {"_error": str(e)}, {}
5757+5858+5959+def test_response_shape(base_url: str):
6060+ print("\n--- response shape ---")
6161+ data, _ = fetch(f"{base_url}{XRPC_PATH}?q=test&limit=3")
6262+ check("returns valid JSON", data is not None and "_error" not in data)
6363+ if not data or "_error" in data or "_http_error" in data:
6464+ return
6565+6666+ check("has actors array", isinstance(data.get("actors"), list))
6767+ actors = data.get("actors", [])
6868+ if actors:
6969+ a = actors[0]
7070+ check("actor has did", "did" in a)
7171+ check("actor has handle", "handle" in a)
7272+ check("did starts with did:", a.get("did", "").startswith("did:"))
7373+7474+7575+def test_known_handle(base_url: str):
7676+ print("\n--- known handle ---")
7777+ data, _ = fetch(f"{base_url}{XRPC_PATH}?q=zzstoatzz&limit=10")
7878+ if not data or "_error" in data or "_http_error" in data:
7979+ check("fetch succeeded", False)
8080+ return
8181+8282+ actors = data.get("actors", [])
8383+ handles = [a.get("handle", "") for a in actors]
8484+ check("zzstoatzz.io in results", "zzstoatzz.io" in handles, f"got {handles[:5]}")
8585+8686+8787+def test_prefix_match(base_url: str):
8888+ print("\n--- prefix match ---")
8989+ data, _ = fetch(f"{base_url}{XRPC_PATH}?q=zzst&limit=10")
9090+ if not data or "_error" in data or "_http_error" in data:
9191+ check("fetch succeeded", False)
9292+ return
9393+9494+ actors = data.get("actors", [])
9595+ handles = [a.get("handle", "") for a in actors]
9696+ check("prefix 'zzst' finds zzstoatzz.io", "zzstoatzz.io" in handles, f"got {handles[:5]}")
9797+9898+9999+def test_cors(base_url: str):
100100+ print("\n--- CORS headers ---")
101101+ _, headers = fetch(f"{base_url}{XRPC_PATH}?q=test&limit=1")
102102+ origin = headers.get("access-control-allow-origin", "")
103103+ check("Access-Control-Allow-Origin: *", origin == "*", f"got '{origin}'")
104104+105105+106106+def test_deprecated_param(base_url: str):
107107+ print("\n--- deprecated param ---")
108108+ data_q, _ = fetch(f"{base_url}{XRPC_PATH}?q=nate&limit=5")
109109+ data_term, _ = fetch(f"{base_url}{XRPC_PATH}?term=nate&limit=5")
110110+111111+ if not data_q or "_error" in data_q or not data_term or "_error" in data_term:
112112+ check("both params work", False)
113113+ return
114114+115115+ actors_q = {a.get("did") for a in data_q.get("actors", [])}
116116+ actors_term = {a.get("did") for a in data_term.get("actors", [])}
117117+ check("?term= returns same as ?q=", actors_q == actors_term, f"q={len(actors_q)}, term={len(actors_term)}")
118118+119119+120120+def test_limit_bounds(base_url: str):
121121+ print("\n--- limit bounds ---")
122122+ data, _ = fetch(f"{base_url}{XRPC_PATH}?q=a&limit=3")
123123+ if not data or "_error" in data or "_http_error" in data:
124124+ check("fetch succeeded", False)
125125+ return
126126+127127+ actors = data.get("actors", [])
128128+ check("limit=3 returns ≤3", len(actors) <= 3, f"got {len(actors)}")
129129+130130+131131+def test_empty_query(base_url: str):
132132+ print("\n--- empty query ---")
133133+ data, _ = fetch(f"{base_url}{XRPC_PATH}?q=")
134134+ if not data or "_error" in data or "_http_error" in data:
135135+ check("fetch succeeded", False)
136136+ return
137137+138138+ actors = data.get("actors", [])
139139+ check("empty query returns empty actors", len(actors) == 0, f"got {len(actors)}")
140140+141141+142142+def test_comparison(base_url: str, queries: list[str]):
143143+ print("\n--- comparison vs public.api.bsky.app ---")
144144+145145+ for q in queries:
146146+ sys.stdout.write(f" comparing '{q}'...")
147147+ sys.stdout.flush()
148148+149149+ ours, _ = fetch(f"{base_url}{XRPC_PATH}?q={q}&limit=10")
150150+ theirs, _ = fetch(f"{BSKY_PUBLIC}{XRPC_PATH}?q={q}&limit=10")
151151+152152+ if not ours or "_error" in ours or not theirs or "_error" in theirs:
153153+ sys.stdout.write(f"\r [{FAIL}] '{q}': fetch failed\n")
154154+ continue
155155+156156+ our_handles = {a.get("handle") for a in ours.get("actors", [])}
157157+ their_handles = {a.get("handle") for a in theirs.get("actors", [])}
158158+159159+ overlap = our_handles & their_handles
160160+ pct = (len(overlap) / len(their_handles) * 100) if their_handles else 0
161161+ sys.stdout.write(
162162+ f"\r [{PASS}] '{q}': {len(overlap)}/{len(their_handles)} overlap ({pct:.0f}%)"
163163+ f" — ours={len(our_handles)}, theirs={len(their_handles)}\n"
164164+ )
165165+166166+167167+def main():
168168+ parser = argparse.ArgumentParser(description="typeahead smoke tests")
169169+ parser.add_argument("--url", required=True, help="typeahead service URL")
170170+ parser.add_argument("--compare", action="store_true", help="compare results vs public.api.bsky.app")
171171+ parser.add_argument(
172172+ "--queries",
173173+ nargs="+",
174174+ default=["nate", "zzstoatzz", "paul", "dan", "sky"],
175175+ help="queries for comparison test",
176176+ )
177177+ args = parser.parse_args()
178178+179179+ print(f"typeahead: {args.url}")
180180+181181+ test_response_shape(args.url)
182182+ test_known_handle(args.url)
183183+ test_prefix_match(args.url)
184184+ test_cors(args.url)
185185+ test_deprecated_param(args.url)
186186+ test_limit_bounds(args.url)
187187+ test_empty_query(args.url)
188188+189189+ if args.compare:
190190+ test_comparison(args.url, args.queries)
191191+ else:
192192+ print(f"\n--- comparison ---")
193193+ print(f" [{SKIP}] skipped (use --compare)")
194194+195195+ print()
196196+ if failures == 0:
197197+ print("all checks passed.")
198198+ else:
199199+ print(f"{failures} check(s) failed.")
200200+ return 1 if failures else 0
201201+202202+203203+if __name__ == "__main__":
204204+ sys.exit(main())