From 7c35b03b8142554d8f085a473c9b6013e0b50ff4 Mon Sep 17 00:00:00 2001
From: PianoNic <79938743+Pianonic@users.noreply.github.com>
Date: Wed, 4 Nov 2026 15:19:05 +0100
Subject: [PATCH] Moved GIF search to Teams' own command, without an API key
---
.claude/CLAUDE.md | 2 +-
src/application/abstractions/i_gif_search.py | 2 +-
src/application/abstractions/i_teams_chat.py | 3 +
src/application/options/giphy_options.py | 5 +-
src/application/queries/search_gifs_query.py | 4 +-
src/infrastructure/giphy/gif_search.py | 107 +++++++++++-------
src/infrastructure/teams/teams_chat_client.py | 25 ++++
tests/test_outnote2.py | 8 +-
tests/test_teams_extras.py | 90 ++++++++++-----
9 files changed, 159 insertions(+), 87 deletions(-)
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 53c9dbd..55ea31d 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -110,7 +110,7 @@ src/
microsoft/ # AccountRunner, OutNoteClientFactory, BrokerCache, SharedBroker, AccountCache, MicrosoftHttpClients, DeviceLoginClient, BrowserSignInClient, ProfileReader
graph/ # TeamsFiles, GraphSearch, OneNoteClient, OneNoteRunner, PdfConverter, FileThumbnails, ChannelMessages, PeoplePhotos, MailFolderRoles
teams/ # TeamsChatClient (skype token, AMS), ChatReader, ChatSender, EmojiCatalogue
- giphy/ # GifSearch (needs GIPHY_API_KEY; 503 without)
+ giphy/ # GifSearch (Teams' own GIF command with the account's sign-in; 503 when Teams refuses)
files/ # FileTextExtractor (lazy imports, zip-bomb guard, caps, paging)
password_signin/ # JobStore, SignInJob (worker thread per job, MfaPrompt bridge), RateLimiter
tests/ # pytest, fully mocked (no network); services.py resolves the singletons the tests patch
diff --git a/src/application/abstractions/i_gif_search.py b/src/application/abstractions/i_gif_search.py
index 6676557..3ff1173 100644
--- a/src/application/abstractions/i_gif_search.py
+++ b/src/application/abstractions/i_gif_search.py
@@ -10,4 +10,4 @@ class IGifSearch(ABC):
def allow(self, broker: IAccountBroker) -> bool: ...
@abstractmethod
- def search(self, query: str | None, limit: int) -> list[dict]: ...
+ def search(self, broker: IAccountBroker, query: str | None, limit: int) -> list[dict]: ...
diff --git a/src/application/abstractions/i_teams_chat.py b/src/application/abstractions/i_teams_chat.py
index 48f752d..997b1f2 100644
--- a/src/application/abstractions/i_teams_chat.py
+++ b/src/application/abstractions/i_teams_chat.py
@@ -14,3 +14,6 @@ def picture(self, broker: IAccountBroker, chat_id: str) -> bytes | None: ...
@abstractmethod
def custom_emoji(self, broker: IAccountBroker, document_id: str, animated: bool) -> bytes | None: ...
+
+ @abstractmethod
+ def gif_command(self, broker: IAccountBroker, query: str, limit: int, rating: str) -> list: ...
diff --git a/src/application/options/giphy_options.py b/src/application/options/giphy_options.py
index 2eac499..f71c8e3 100644
--- a/src/application/options/giphy_options.py
+++ b/src/application/options/giphy_options.py
@@ -4,13 +4,10 @@
@dataclass
class GiphyOptions:
- """Giphy's rating filter, and its key (free from developers.giphy.com), read at each search: without one the search answers 503."""
+ """Giphy's rating filter for GIF search."""
rating: str
@classmethod
def from_environment(cls) -> "GiphyOptions":
return cls(rating=os.getenv("GIPHY_RATING", "g"))
-
- def api_key(self) -> str | None:
- return os.getenv("GIPHY_API_KEY") or None
diff --git a/src/application/queries/search_gifs_query.py b/src/application/queries/search_gifs_query.py
index b3218d2..4c6e07f 100644
--- a/src/application/queries/search_gifs_query.py
+++ b/src/application/queries/search_gifs_query.py
@@ -27,12 +27,12 @@ async def handle(self, query: SearchGifsQuery) -> list[dict]:
RefreshTokenFormat.check(query.refresh_token)
def run(note, broker):
- # Only a live Microsoft sign-in may spend the GIF key: the token must mint a Graph token (cached after the first call), and each account is rate limited.
+ # Only a live Microsoft sign-in may search: the token must mint a Graph token (cached after the first call), and each account is rate limited.
broker(self._options.device_client_id)
if not self._gifs.allow(broker):
raise ServiceError(429, "Too many GIF searches; wait a minute.", headers={"Retry-After": "60"})
try:
- return self._gifs.search(query.q, query.limit)
+ return self._gifs.search(broker, query.q, query.limit)
except GifSearchUnavailable as ex:
raise ServiceError(503, str(ex))
except Exception as ex:
diff --git a/src/infrastructure/giphy/gif_search.py b/src/infrastructure/giphy/gif_search.py
index 2410953..e4688c6 100644
--- a/src/infrastructure/giphy/gif_search.py
+++ b/src/infrastructure/giphy/gif_search.py
@@ -1,57 +1,73 @@
-"""GIF search over Giphy's public API.
+"""GIF search through Teams, with the account's own Microsoft sign-in and no key of our own.
-Teams shows Giphy GIFs as a plain `
`
-in the message body, so a GIF picked here sends the same way. The Teams clients query Giphy with
-Microsoft's own key; Schuly needs its own (`GIPHY_API_KEY`, free from developers.giphy.com).
-Without one the search answers 503.
+The Teams clients ask their middle tier for Giphy results (see `TeamsChatClient.gif_command`). The answer is a list of
+`{composeType, composeValue, fieldValues: [{fieldName, fieldValue}]}`. The route is undocumented and the names in it were
+read from Teams' client code, not from a live answer, so the GIF is found by its Giphy URL rather than by a field name.
+A GIF sends as a plain `
` in the message body.
"""
+import html
+import logging
+import re
import time
-from urllib.parse import urlencode
-from entrance import http_client
from src.application.abstractions.i_gif_search import IGifSearch
+from src.application.abstractions.i_teams_chat import ITeamsChat
from src.application.options.giphy_options import GiphyOptions
from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer
+from src.domain.errors.chat_service_error import ChatServiceError
from src.domain.errors.gif_search_unavailable import GifSearchUnavailable
from src.infrastructure.microsoft.account_cache import AccountCache
class GifSearch(IGifSearch):
- """GIF search over Giphy's public API, with a per-account rate limit."""
+ """GIF search over the Teams middle tier, cached and rate limited per account because each tenant has its own GIF policy."""
- API = "https://api.giphy.com/v1/gifs"
+ log = logging.getLogger("schuly.microsoft")
MAX_LIMIT = 50
- RESULTS_TTL = 300 # identical searches within 5 minutes share one Giphy call
+ RESULTS_TTL = 300 # identical searches within 5 minutes share one call
RATE_PER_MINUTE = 30
- http_client = staticmethod(http_client)
+ REFUSED = (401, 403, 404) # this account or tenant has no GIFs: the picker hides them
+ _URL = re.compile(r"""https://[^\s"'<>\\]+""")
+ _PREVIEW_FIELD = re.compile("thumb|preview|small|still", re.IGNORECASE)
- def __init__(self, html: ChatHtmlSanitizer, options: GiphyOptions):
- self._html = html
+ def __init__(self, html_sanitizer: ChatHtmlSanitizer, options: GiphyOptions, teams: ITeamsChat):
+ self._html = html_sanitizer
self._options = options
- self._results = AccountCache(max_items=500)
+ self._teams = teams
- def _image(self, images: dict, *names: str) -> dict:
+ def _gif_urls(self, text: str) -> list[str]:
+ urls = (url.split("?")[0] for url in self._URL.findall(html.unescape(text)))
+ return [url for url in urls if self._html.is_gif_host(url)]
+
+ def _field(self, fields: dict[str, str], compose: str, *names: str) -> str | None:
+ """A field whose name holds one of `names`, else that attribute of the compose value when it is markup."""
for name in names:
- img = images.get(name) or {}
- if img.get("url"):
- return img
- return {}
+ for key, value in fields.items():
+ if name in key and value:
+ return value
+ m = re.search(rf'\b{name}="([^"]+)"', compose)
+ if m:
+ return html.unescape(m.group(1))
+ return None
def _int(self, value) -> int | None:
try:
- return int(value)
+ return int(float(value))
except (TypeError, ValueError):
return None
- def _shape(self, item: dict) -> dict | None:
- images = item.get("images") or {}
- full = self._image(images, "downsized_medium", "downsized", "original")
- preview = self._image(images, "fixed_width_small", "fixed_width", "preview_gif") or full
- url = (full.get("url") or "").split("?")[0]
- if not self._html.is_gif_host(url):
+ def _shape(self, item) -> dict | None:
+ if not isinstance(item, dict):
+ return None
+ fields = {str(f.get("fieldName") or "").lower(): str(f.get("fieldValue") or "") for f in item.get("fieldValues") or [] if isinstance(f, dict)}
+ compose = str(item.get("composeValue") or "")
+ previews = [u for name, value in fields.items() if self._PREVIEW_FIELD.search(name) for u in self._gif_urls(value)]
+ full = self._gif_urls(compose) + [u for name, value in fields.items() if not self._PREVIEW_FIELD.search(name) for u in self._gif_urls(value)]
+ url = next(iter(full or previews), None)
+ if not url:
return None
- return {"id": item.get("id"), "title": item.get("title") or "GIF", "url": url, "previewUrl": (preview.get("url") or url).split("?")[0], "width": self._int(full.get("width")), "height": self._int(full.get("height"))}
+ return {"id": url, "title": self._field(fields, compose, "title", "alt") or "GIF", "url": url, "previewUrl": next(iter(previews), url), "width": self._int(self._field(fields, compose, "width")), "height": self._int(self._field(fields, compose, "height"))}
def allow(self, broker) -> bool:
"""A fixed one-minute window per account: at most RATE_PER_MINUTE searches."""
@@ -64,23 +80,28 @@ def allow(self, broker) -> bool:
cache.set(window, used + 1, 120)
return True
- def search(self, query: str | None, limit: int) -> list[dict]:
- key = self._options.api_key()
- if not key:
- raise GifSearchUnavailable("GIF search needs a Giphy API key (GIPHY_API_KEY).")
- cache_key = ((query or "").strip().lower() or None, limit)
- hit = self._results.get(cache_key)
+ def search(self, broker, query: str | None, limit: int) -> list[dict]:
+ cache = AccountCache.for_broker(broker)
+ cache_key = ("gif_results", (query or "").strip().lower(), limit)
+ hit = cache.get(cache_key)
if hit is not None:
return hit
- found = self._search(key, query, limit)
- self._results.set(cache_key, found, self.RESULTS_TTL)
+ found = self._search(broker, (query or "").strip(), limit)
+ cache.set(cache_key, found, self.RESULTS_TTL)
return found
- def _search(self, key: str, query: str | None, limit: int) -> list[dict]:
- params = {"api_key": key, "limit": max(1, min(limit, self.MAX_LIMIT)), "rating": self._options.rating}
- if query:
- params["q"] = query
- r = self.http_client().request("GET", f"{self.API}/{'search' if query else 'trending'}?{urlencode(params)}", timeout=15)
- if r.status_code != 200:
- raise RuntimeError(f"Giphy answered {r.status_code}")
- return [g for g in (self._shape(i) for i in r.json().get("data") or []) if g]
+ def _search(self, broker, query: str, limit: int) -> list[dict]:
+ limit = max(1, min(limit, self.MAX_LIMIT))
+ try:
+ items = self._teams.gif_command(broker, query, limit, self._options.rating)
+ except ChatServiceError as ex:
+ if ex.status in self.REFUSED:
+ self.log.warning("Teams refused the GIF search (%s)", ex.status)
+ raise GifSearchUnavailable("Teams has no GIF search for this account.") from None
+ raise
+ found = [g for g in map(self._shape, items) if g]
+ if items and not found:
+ # Names only: this is how an unexpected answer gets noticed, since the route has no documentation.
+ first = items[0] if isinstance(items[0], dict) else {}
+ self.log.warning("Teams GIF answer had no Giphy URL; keys %s, fields %s", sorted(first), sorted(str(f.get("fieldName")) for f in first.get("fieldValues") or [] if isinstance(f, dict)))
+ return found[:limit]
diff --git a/src/infrastructure/teams/teams_chat_client.py b/src/infrastructure/teams/teams_chat_client.py
index a308f63..2a0bfcb 100644
--- a/src/infrastructure/teams/teams_chat_client.py
+++ b/src/infrastructure/teams/teams_chat_client.py
@@ -10,9 +10,13 @@
3. GET {chatService}/v1/users/ME/conversations/{chatId}?view=msnp24Equivalent (Authentication: skypetoken=...)
4. GET (Authorization: skype_token ...) -> image bytes
+GIF search is a Teams command on the middle tier, also named by `authz` (`regionGtms.middleTier`). It takes the same access
+token and skype token, and the tenant's GIF policy applies to it. It is undocumented; the route is what the Teams web client calls.
+
The skype token and each chat's picture URL are cached per account (see `AccountCache`).
"""
+import json
import time
from concurrent.futures import ThreadPoolExecutor, wait
from urllib.parse import quote, urlsplit
@@ -43,6 +47,9 @@ class TeamsChatClient(ITeamsChat):
_STREAM_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
CUSTOM_EMOJI_VIEWS = {True: "imgt2_anim", False: "img_thumb_tiny"}
MAX_EMOJI_BYTES = 2 * 1024 * 1024
+ GIF_COMMAND = "beta/commands/com.microsoft.teamspace.inputextension.giphy/execute?version=1.0"
+ GIF_LANGUAGE = "en-us"
+ MIDDLE_TIER_HOSTS = ("teams.microsoft.com", "teams.cloud.microsoft") # both tokens ride on this request
http_client = staticmethod(http_client)
def __init__(self, http: IMicrosoftHttpClients):
@@ -88,6 +95,7 @@ def _new_session(self, broker, cache) -> tuple[str, str]:
session = (skype, service)
ams = self._media_host((body.get("regionGtms") or {}).get("amsV2") or (body.get("regionGtms") or {}).get("ams"))
cache.set("teams_ams", ams, max(60, lifetime - self.SESSION_MARGIN))
+ cache.set("teams_middle_tier", ((body.get("regionGtms") or {}).get("middleTier") or "").rstrip("/"), max(60, lifetime - self.SESSION_MARGIN))
cache.set("teams_session", session, max(60, lifetime - self.SESSION_MARGIN))
return session
@@ -95,6 +103,23 @@ def _drop_session(self, broker):
# A skype token the chat service no longer accepts; the next call mints a fresh one.
AccountCache.for_broker(broker).set("teams_session", None, 0)
+ def gif_command(self, broker, query: str, limit: int, rating: str) -> list:
+ """Giphy results from Teams' own GIF command; an empty query asks for what is trending."""
+ skype, _ = self._session(broker)
+ base = AccountCache.for_broker(broker).get("teams_middle_tier") or ""
+ parts = urlsplit(base)
+ if parts.scheme != "https" or parts.port not in (None, 443) or (parts.hostname or "").lower() not in self.MIDDLE_TIER_HOSTS:
+ raise ChatServiceError(502, "authz returned no middle tier")
+ params = {"query": query, "rating": rating, "limit": str(limit), "lang": self.GIF_LANGUAGE}
+ headers = {"Authorization": f"Bearer {broker(self.TEAMS_CLIENT, self.SPACES_SCOPE)}", "X-Skypetoken": skype, "Content-Type": "application/json;charset=UTF-8"}
+ r = self.http_client().post(f"{base}/{self.GIF_COMMAND}", headers=headers, content=json.dumps([{"name": k, "value": v} for k, v in params.items()]).encode(), timeout=self._TIMEOUT)
+ if r.status_code != 200:
+ if r.status_code == 401:
+ self._drop_session(broker)
+ raise ChatServiceError(r.status_code, "GIF command refused")
+ result = r.json().get("result")
+ return result if isinstance(result, list) else []
+
def _media_host(self, value) -> str | None:
"""The media service base URL when it is an https Microsoft media host, else None."""
if not isinstance(value, str):
diff --git a/tests/test_outnote2.py b/tests/test_outnote2.py
index f5d7c3e..a1b4910 100644
--- a/tests/test_outnote2.py
+++ b/tests/test_outnote2.py
@@ -13,7 +13,7 @@
from outnote import GraphError, OutNote
from outnote._native import message_from_native
-from tests.services import AccountRunner, FileFetchError, ResourceTooLarge, Upload, _BrokerCache, chat_send, config, gifs, http_clients, onenote, outnote_client, profile, teams_files
+from tests.services import AccountRunner, FileFetchError, ResourceTooLarge, Upload, _BrokerCache, chat_send, config, http_clients, onenote, outnote_client, profile, teams_files
from src.api.app import app
from src.domain.errors.service_error import ServiceError
@@ -113,12 +113,6 @@ def test_entrance_http_error_is_a_tuple_and_profile_catches_it_for_real(monkeypa
assert me["id"] == "oid-1" and ttl == profile.CLAIMS_TTL
-def test_a_gif_search_goes_through_the_real_client_with_its_query(monkeypatch, server):
- monkeypatch.setattr(gifs, "API", server.url)
- assert gifs._search("k", "red cat", 5) == []
- assert server.seen[0]["path"].startswith("/search?") and "q=red+cat" in server.seen[0]["path"]
-
-
def test_an_upload_session_goes_through_the_real_client(server):
size = chat_send.SIMPLE_UPLOAD_MAX + 7
diff --git a/tests/test_teams_extras.py b/tests/test_teams_extras.py
index d14b078..7bd772d 100644
--- a/tests/test_teams_extras.py
+++ b/tests/test_teams_extras.py
@@ -382,14 +382,67 @@ def test_a_long_expanded_list_is_used_when_the_members_route_is_throttled(env):
assert len(_invited(note)) == 20
-def test_gif_search_needs_a_giphy_key(env, monkeypatch):
- monkeypatch.delenv("GIPHY_API_KEY", raising=False)
- r = TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"})
- assert r.status_code == 503
+def _gif_http(monkeypatch, status=200, result=None, middle_tier="https://teams.microsoft.com/api/mt/emea"):
+ from tests.services import teams_chat
+ calls = []
+
+ class Http:
+ def post(self, url, headers=None, content=None, timeout=None):
+ if url == teams_chat.AUTHZ_URL:
+ return FakeResponse(200, {"tokens": {"skypeToken": "skype", "expiresIn": 4000}, "regionGtms": {"chatService": "https://ch.ng.msg.teams.microsoft.com", "middleTier": middle_tier}})
+ calls.append({"url": url, "headers": headers, "body": json.loads(content)})
+ return FakeResponse(status, {"result": result or []})
+
+ monkeypatch.setattr(teams_chat, "http_client", lambda: Http())
+ return calls
+
+
+def _gif_item(url=GIF_URL):
+ return {"composeType": "img", "composeValue": f'
', "fieldValues": [{"fieldName": "thumbnailUrl", "fieldValue": url.replace("giphy.gif", "200w.gif")}]}
+
+
+def test_gif_search_asks_teams_with_the_accounts_own_tokens(env, monkeypatch):
+ calls = _gif_http(monkeypatch, result=[_gif_item()])
+ client = TestClient(app)
+ r = client.get("/api/gifs/search", headers=H, params={"q": "cat", "limit": 5})
+ assert r.json() == [{"id": GIF_URL, "title": "Cat", "url": GIF_URL, "previewUrl": GIF_URL.replace("giphy.gif", "200w.gif"), "width": 200, "height": 150}]
+ assert calls[0]["url"] == "https://teams.microsoft.com/api/mt/emea/beta/commands/com.microsoft.teamspace.inputextension.giphy/execute?version=1.0"
+ assert calls[0]["headers"]["X-Skypetoken"] == "skype" and calls[0]["headers"]["Authorization"].startswith("Bearer ")
+ assert {"name": "query", "value": "cat"} in calls[0]["body"] and {"name": "limit", "value": "5"} in calls[0]["body"]
+ client.get("/api/gifs/search", headers=H, params={"q": "Cat", "limit": 5})
+ assert len(calls) == 1 # cached by (query, limit)
+
+
+def test_gif_trending_is_the_command_with_an_empty_query(env, monkeypatch):
+ calls = _gif_http(monkeypatch, result=[_gif_item()])
+ assert TestClient(app).get("/api/gifs/trending", headers=H).status_code == 200
+ assert {"name": "query", "value": ""} in calls[0]["body"]
+
+def test_gif_search_keeps_only_giphy_urls(env, monkeypatch):
+ _gif_http(monkeypatch, result=[_gif_item("https://evil.example/giphy.gif"), {"composeValue": 7, "fieldValues": ["x"]}, "nonsense", _gif_item()])
+ assert [g["url"] for g in TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).json()] == [GIF_URL]
-def test_gif_search_needs_a_microsoft_sign_in(env, monkeypatch):
- monkeypatch.setenv("GIPHY_API_KEY", "k")
+
+@pytest.mark.parametrize("status", [401, 403, 404])
+def test_a_refusal_from_teams_reads_as_no_gifs_here(env, monkeypatch, status):
+ _gif_http(monkeypatch, status=status)
+ assert TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).status_code == 503
+
+
+def test_a_failing_gif_command_stays_an_error(env, monkeypatch):
+ _gif_http(monkeypatch, status=500)
+ assert TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).status_code == 502
+
+
+@pytest.mark.parametrize("host", ["https://evil.example/api/mt/emea", "http://teams.microsoft.com/api/mt/emea", "https://teams.microsoft.com.evil.example/mt", ""])
+def test_the_tokens_only_go_to_a_teams_middle_tier(env, monkeypatch, host):
+ calls = _gif_http(monkeypatch, middle_tier=host)
+ assert TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).status_code == 502
+ assert calls == []
+
+
+def test_gif_search_needs_a_microsoft_sign_in(env):
client = TestClient(app)
assert client.get("/api/gifs/search", params={"q": "cat"}).status_code == 422
assert client.get("/api/gifs/trending").status_code == 422
@@ -398,32 +451,12 @@ def test_gif_search_needs_a_microsoft_sign_in(env, monkeypatch):
def test_gif_search_is_rate_limited_per_account(env, monkeypatch):
from tests.services import gifs
- monkeypatch.setenv("GIPHY_API_KEY", "k")
- monkeypatch.setattr(gifs, "_search", lambda key, query, limit: [])
+ monkeypatch.setattr(gifs, "_search", lambda broker, query, limit: [])
client = TestClient(app)
codes = [client.get("/api/gifs/search", headers=H, params={"q": f"q{i}"}).status_code for i in range(gifs.RATE_PER_MINUTE + 1)]
assert codes[:-1] == [200] * gifs.RATE_PER_MINUTE and codes[-1] == 429
-def test_gif_search_returns_only_giphy_urls(env, monkeypatch):
- from tests.services import gifs
- calls = []
-
- class Http:
- def request(self, method, url, timeout=None):
- calls.append(url)
- assert method == "GET" and "/search?" in url and "q=cat" in url
- return FakeResponse(200, {"data": [{"id": "g1", "title": "Cat", "images": {"downsized_medium": {"url": GIF_URL + "?cid=1", "width": "200", "height": "150"}, "fixed_width_small": {"url": GIF_URL}}}, {"id": "bad", "images": {"original": {"url": "https://evil.example/x.gif"}}}]})
-
- monkeypatch.setenv("GIPHY_API_KEY", "k")
- monkeypatch.setattr(gifs, "http_client", lambda: Http())
- gifs._results.set(("cat", 5), None, 0)
- r = TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat", "limit": 5})
- assert r.json() == [{"id": "g1", "title": "Cat", "url": GIF_URL, "previewUrl": GIF_URL, "width": 200, "height": 150}]
- TestClient(app).get("/api/gifs/search", headers=H, params={"q": "Cat", "limit": 5})
- assert len(calls) == 1 # cached by (query, limit)
-
-
def test_account_cache_expires_and_is_bounded(monkeypatch):
clock = [100.0]
monkeypatch.setattr(account_cache.time, "monotonic", lambda: clock[0])
@@ -441,8 +474,7 @@ def test_no_response_carrying_a_token_is_cacheable(env, http, monkeypatch):
"""Every route that can hand back a rotated refresh token must forbid storing the response."""
from tests.services import gifs
note, _ = env
- monkeypatch.setenv("GIPHY_API_KEY", "k")
- monkeypatch.setattr(gifs, "_search", lambda key, query, limit: [])
+ monkeypatch.setattr(gifs, "_search", lambda broker, query, limit: [])
monkeypatch.setattr(note, "get", lambda path, params=None, raw=False: PNG if raw == "bytes" else {"id": "me-id"})
client = TestClient(app)
responses = [