| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| from __future__ import annotations |
| |
| import io |
| import json |
| import urllib.error |
| from unittest.mock import patch |
| |
| import pytest |
| |
| from oauth_draft.message_id import main, parse_args, root_message_id |
| |
| |
| def test_parse_args_collects_thread_ids(): |
| args = parse_args(["t1", "t2", "t3"]) |
| assert args.thread_ids == ["t1", "t2", "t3"] |
| assert args.json is False |
| |
| |
| def test_parse_args_requires_at_least_one_thread(): |
| with pytest.raises(SystemExit): |
| parse_args([]) |
| |
| |
| # --- network-mocked helpers ------------------------------------------------ |
| |
| |
| class _FakeResponse: |
| def __init__(self, payload: bytes): |
| self._payload = payload |
| |
| def __enter__(self): |
| return self |
| |
| def __exit__(self, *exc): |
| return False |
| |
| def read(self): |
| return self._payload |
| |
| |
| def _thread_payload(message_id: str | None, *, messages: bool = True) -> bytes: |
| if not messages: |
| return json.dumps({"messages": []}).encode() |
| headers = [{"name": "Subject", "value": "report"}] |
| if message_id is not None: |
| # Mixed-case header name on purpose — lookup is case-insensitive. |
| headers.append({"name": "Message-Id", "value": message_id}) |
| return json.dumps({"messages": [{"payload": {"headers": headers}}]}).encode() |
| |
| |
| def test_root_message_id_extracts_header_case_insensitively(): |
| payload = _thread_payload("<abc@example.com>") |
| with patch( |
| "oauth_draft.message_id.urllib.request.urlopen", |
| return_value=_FakeResponse(payload), |
| ) as mock_open: |
| mid = root_message_id("token", "tid-1") |
| assert mid == "<abc@example.com>" |
| request = mock_open.call_args.args[0] |
| assert "/threads/tid-1" in request.full_url |
| assert "format=metadata" in request.full_url |
| assert "metadataHeaders=Message-ID" in request.full_url |
| |
| |
| def test_root_message_id_uses_first_message_as_root(): |
| payload = json.dumps( |
| { |
| "messages": [ |
| {"payload": {"headers": [{"name": "Message-ID", "value": "<root@x>"}]}}, |
| {"payload": {"headers": [{"name": "Message-ID", "value": "<reply@x>"}]}}, |
| ] |
| } |
| ).encode() |
| with patch( |
| "oauth_draft.message_id.urllib.request.urlopen", |
| return_value=_FakeResponse(payload), |
| ): |
| assert root_message_id("token", "tid") == "<root@x>" |
| |
| |
| def test_root_message_id_returns_none_when_no_messages(): |
| with patch( |
| "oauth_draft.message_id.urllib.request.urlopen", |
| return_value=_FakeResponse(_thread_payload(None, messages=False)), |
| ): |
| assert root_message_id("token", "tid") is None |
| |
| |
| def test_root_message_id_returns_none_when_header_absent(): |
| with patch( |
| "oauth_draft.message_id.urllib.request.urlopen", |
| return_value=_FakeResponse(_thread_payload(None)), |
| ): |
| assert root_message_id("token", "tid") is None |
| |
| |
| def test_root_message_id_raises_on_http_error(): |
| err = urllib.error.HTTPError( |
| url="https://x", |
| code=404, |
| msg="Not Found", |
| hdrs=None, # type: ignore[arg-type] |
| fp=io.BytesIO(b'{"error": "missing"}'), |
| ) |
| with patch( |
| "oauth_draft.message_id.urllib.request.urlopen", |
| side_effect=err, |
| ): |
| with pytest.raises(SystemExit) as excinfo: |
| root_message_id("token", "tid-x") |
| assert "threads.get failed (404) for tid-x" in str(excinfo.value) |
| |
| |
| # --- main ------------------------------------------------------------------ |
| |
| |
| def _make_creds_file(tmp_path): |
| p = tmp_path / "creds.json" |
| p.write_text( |
| json.dumps( |
| { |
| "client_id": "cid", |
| "client_secret": "secret", |
| "refresh_token": "refresh", |
| } |
| ) |
| ) |
| return p |
| |
| |
| def test_main_tsv_output(tmp_path, capsys): |
| creds = _make_creds_file(tmp_path) |
| with ( |
| patch("oauth_draft.message_id.refresh_access_token", return_value="tok"), |
| patch( |
| "oauth_draft.message_id.root_message_id", |
| side_effect=["<a@x>", None], |
| ), |
| ): |
| rc = main(["--credentials", str(creds), "t1", "t2"]) |
| assert rc == 0 |
| out = capsys.readouterr().out.splitlines() |
| assert out == ["t1\t<a@x>", "t2\t"] |
| |
| |
| def test_main_json_output(tmp_path, capsys): |
| creds = _make_creds_file(tmp_path) |
| with ( |
| patch("oauth_draft.message_id.refresh_access_token", return_value="tok"), |
| patch("oauth_draft.message_id.root_message_id", return_value="<a@x>"), |
| ): |
| rc = main(["--credentials", str(creds), "--json", "t1"]) |
| assert rc == 0 |
| assert json.loads(capsys.readouterr().out) == {"t1": "<a@x>"} |