blob: 08326d49d97de0a32195030bcda521aee92d4b03 [file]
# 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