| # 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.mark_threads_read import ( |
| list_thread_ids, |
| main, |
| modify_thread, |
| parse_args, |
| ) |
| |
| |
| def test_default_action_is_remove_unread(): |
| args = parse_args(["--query", "in:inbox"]) |
| assert args.add_label == [] |
| assert args.remove_label == ["UNREAD"] |
| assert args.execute is False |
| assert args.max is None |
| |
| |
| def test_explicit_add_label_does_not_inject_unread_default(): |
| args = parse_args(["--query", "in:inbox", "--add-label", "STARRED"]) |
| assert args.add_label == ["STARRED"] |
| assert args.remove_label == [] |
| |
| |
| def test_explicit_remove_label_does_not_inject_unread_default(): |
| args = parse_args(["--query", "in:inbox", "--remove-label", "INBOX"]) |
| assert args.add_label == [] |
| assert args.remove_label == ["INBOX"] |
| |
| |
| def test_repeat_label_flags_accumulate(): |
| args = parse_args( |
| [ |
| "--query", |
| "in:inbox", |
| "--add-label", |
| "A", |
| "--add-label", |
| "B", |
| "--remove-label", |
| "C", |
| ] |
| ) |
| assert args.add_label == ["A", "B"] |
| assert args.remove_label == ["C"] |
| |
| |
| def test_execute_and_max_flags(): |
| args = parse_args(["--query", "x", "--execute", "--max", "5"]) |
| assert args.execute is True |
| assert args.max == 5 |
| |
| |
| # --- 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 test_list_thread_ids_paginates_until_no_token(): |
| pages = [ |
| _FakeResponse( |
| json.dumps( |
| { |
| "threads": [{"id": "t1"}, {"id": "t2"}], |
| "nextPageToken": "page2", |
| } |
| ).encode() |
| ), |
| _FakeResponse(json.dumps({"threads": [{"id": "t3"}]}).encode()), |
| ] |
| with patch( |
| "oauth_draft.mark_threads_read.urllib.request.urlopen", |
| side_effect=pages, |
| ) as mock_open: |
| ids = list_thread_ids("token", "in:inbox") |
| assert ids == ["t1", "t2", "t3"] |
| # Second call must include the pageToken from the first response. |
| _, second_call = mock_open.call_args_list |
| second_url = second_call.args[0].full_url |
| assert "pageToken=page2" in second_url |
| |
| |
| def test_list_thread_ids_raises_on_http_error(): |
| err = urllib.error.HTTPError( |
| url="https://x", |
| code=429, |
| msg="Too Many", |
| hdrs=None, # type: ignore[arg-type] |
| fp=io.BytesIO(b'{"error": "rate"}'), |
| ) |
| with patch( |
| "oauth_draft.mark_threads_read.urllib.request.urlopen", |
| side_effect=err, |
| ): |
| with pytest.raises(SystemExit) as excinfo: |
| list_thread_ids("token", "q") |
| assert "threads.list failed (429)" in str(excinfo.value) |
| |
| |
| def test_modify_thread_sends_label_payload(): |
| with patch("oauth_draft.mark_threads_read.urllib.request.urlopen") as mock_open: |
| mock_open.return_value = _FakeResponse(b"{}") |
| modify_thread("token", "tid-1", ["STARRED"], ["UNREAD"]) |
| request = mock_open.call_args.args[0] |
| assert request.method == "POST" |
| assert "/threads/tid-1/modify" in request.full_url |
| payload = json.loads(request.data) |
| assert payload == {"addLabelIds": ["STARRED"], "removeLabelIds": ["UNREAD"]} |
| |
| |
| def test_modify_thread_omits_empty_label_lists(): |
| with patch("oauth_draft.mark_threads_read.urllib.request.urlopen") as mock_open: |
| mock_open.return_value = _FakeResponse(b"{}") |
| modify_thread("token", "tid-2", [], ["UNREAD"]) |
| payload = json.loads(mock_open.call_args.args[0].data) |
| assert payload == {"removeLabelIds": ["UNREAD"]} |
| |
| |
| def test_modify_thread_raises_on_http_error(): |
| err = urllib.error.HTTPError( |
| url="https://x", |
| code=500, |
| msg="Boom", |
| hdrs=None, # type: ignore[arg-type] |
| fp=io.BytesIO(b'{"error": "server"}'), |
| ) |
| with patch( |
| "oauth_draft.mark_threads_read.urllib.request.urlopen", |
| side_effect=err, |
| ): |
| with pytest.raises(SystemExit) as excinfo: |
| modify_thread("token", "tid", [], ["UNREAD"]) |
| assert "threads.modify failed (500) for tid" 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_dry_run_lists_ids_and_skips_modify(tmp_path, capsys): |
| creds = _make_creds_file(tmp_path) |
| with ( |
| patch( |
| "oauth_draft.mark_threads_read.refresh_access_token", |
| return_value="tok", |
| ), |
| patch( |
| "oauth_draft.mark_threads_read.list_thread_ids", |
| return_value=["t1", "t2"], |
| ), |
| patch("oauth_draft.mark_threads_read.modify_thread") as modify, |
| ): |
| rc = main( |
| [ |
| "--credentials", |
| str(creds), |
| "--query", |
| "in:inbox is:unread", |
| ] |
| ) |
| assert rc == 0 |
| modify.assert_not_called() |
| out = capsys.readouterr().out |
| assert "t1" in out and "t2" in out |
| |
| |
| def test_main_execute_calls_modify_per_thread(tmp_path): |
| creds = _make_creds_file(tmp_path) |
| with ( |
| patch( |
| "oauth_draft.mark_threads_read.refresh_access_token", |
| return_value="tok", |
| ), |
| patch( |
| "oauth_draft.mark_threads_read.list_thread_ids", |
| return_value=["t1", "t2", "t3"], |
| ), |
| patch("oauth_draft.mark_threads_read.modify_thread") as modify, |
| ): |
| rc = main( |
| [ |
| "--credentials", |
| str(creds), |
| "--query", |
| "q", |
| "--execute", |
| ] |
| ) |
| assert rc == 0 |
| assert modify.call_count == 3 |
| # default action: remove UNREAD |
| for call in modify.call_args_list: |
| _, _, add, remove = call.args |
| assert add == [] |
| assert remove == ["UNREAD"] |
| |
| |
| def test_main_max_truncates(tmp_path): |
| creds = _make_creds_file(tmp_path) |
| with ( |
| patch( |
| "oauth_draft.mark_threads_read.refresh_access_token", |
| return_value="tok", |
| ), |
| patch( |
| "oauth_draft.mark_threads_read.list_thread_ids", |
| return_value=["t1", "t2", "t3", "t4", "t5"], |
| ), |
| patch("oauth_draft.mark_threads_read.modify_thread") as modify, |
| ): |
| rc = main( |
| [ |
| "--credentials", |
| str(creds), |
| "--query", |
| "q", |
| "--execute", |
| "--max", |
| "2", |
| ] |
| ) |
| assert rc == 0 |
| assert modify.call_count == 2 |
| |
| |
| def test_main_returns_1_when_modify_fails(tmp_path): |
| creds = _make_creds_file(tmp_path) |
| with ( |
| patch( |
| "oauth_draft.mark_threads_read.refresh_access_token", |
| return_value="tok", |
| ), |
| patch( |
| "oauth_draft.mark_threads_read.list_thread_ids", |
| return_value=["t1"], |
| ), |
| patch( |
| "oauth_draft.mark_threads_read.modify_thread", |
| side_effect=SystemExit("boom"), |
| ), |
| ): |
| rc = main( |
| [ |
| "--credentials", |
| str(creds), |
| "--query", |
| "q", |
| "--execute", |
| ] |
| ) |
| assert rc == 1 |