blob: 5ef35516bc48aec1483c4918137f058d45385bec [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.
import contextlib
import unittest.mock as mock
from collections.abc import AsyncIterator
from types import SimpleNamespace
import pytest
import atr.models.tabulate as models_tabulate
import atr.tabulate as tabulate
@pytest.mark.parametrize(
("binding_plus_one", "binding_minus_one", "expected"),
[
(2, 0, False),
(3, 3, False),
(3, 2, True),
(4, 0, True),
],
)
def test_binding_vote_passes(binding_plus_one: int, binding_minus_one: int, expected: bool) -> None:
assert tabulate.binding_vote_passes(binding_plus_one, binding_minus_one) is expected
@pytest.mark.asyncio
async def test_vote_committee_returns_incubator_for_podling_round_two(
monkeypatch: pytest.MonkeyPatch,
) -> None:
committee = SimpleNamespace(is_podling=True)
incubator = SimpleNamespace(key="incubator", is_podling=False)
release = _make_release(committee=committee, podling_thread_id="round-one-thread")
query = mock.AsyncMock()
query.get = mock.AsyncMock(return_value=incubator)
data = mock.MagicMock()
data.committee = mock.MagicMock(return_value=query)
monkeypatch.setattr(tabulate.config, "is_dev_environment", lambda: False)
monkeypatch.setattr(tabulate.db, "session", lambda: _mock_db_session(data))
result = await tabulate.vote_committee("threadid", release)
assert result is incubator
data.committee.assert_called_once_with(key="incubator")
query.get.assert_awaited_once()
@pytest.mark.asyncio
async def test_vote_committee_returns_project_committee_for_non_podling_release(
monkeypatch: pytest.MonkeyPatch,
) -> None:
committee = SimpleNamespace(is_podling=False)
release = _make_release(committee=committee, podling_thread_id=None)
monkeypatch.setattr(tabulate.config, "is_dev_environment", lambda: False)
monkeypatch.setattr(tabulate.db, "session", _unexpected_db_session)
result = await tabulate.vote_committee("threadid", release)
assert result is committee
@pytest.mark.asyncio
async def test_vote_committee_returns_project_committee_for_podling_round_one(
monkeypatch: pytest.MonkeyPatch,
) -> None:
committee = SimpleNamespace(is_podling=True)
release = _make_release(committee=committee, podling_thread_id=None)
monkeypatch.setattr(tabulate.config, "is_dev_environment", lambda: False)
monkeypatch.setattr(tabulate.db, "session", _unexpected_db_session)
result = await tabulate.vote_committee("threadid", release)
assert result is committee
def test_vote_outcome_format_fails_when_binding_minus_one_equals_plus_one() -> None:
"""_vote_outcome_format returns (False, ...) when binding -1 >= binding +1."""
passed, _outcome = tabulate._vote_outcome_format(None, 3, 3)
assert passed is False
def test_vote_outcome_format_fails_when_fewer_than_three_binding_plus_one() -> None:
"""_vote_outcome_format returns (False, ...) for fewer than 3 binding +1."""
passed, _outcome = tabulate._vote_outcome_format(None, 2, 0)
assert passed is False
def test_vote_outcome_format_passes_with_three_binding_plus_one() -> None:
"""_vote_outcome_format returns (True, ...) for 3 binding +1 and 0 binding -1."""
passed, _outcome = tabulate._vote_outcome_format(None, 3, 0)
assert passed is True
def test_vote_resolution_body_votes_formats_plural_binding_summary() -> None:
summary = {
"binding_votes": 9,
"binding_votes_yes": 8,
"binding_votes_no": 0,
"binding_votes_abstain": 1,
}
body_lines = list(tabulate._vote_resolution_body_votes({}, summary))
assert body_lines[2] == "Of these binding votes, 8 were +1, 0 were -1, and 1 was 0."
def test_vote_resolution_body_votes_formats_singular_binding_summary() -> None:
summary = {
"binding_votes": 9,
"binding_votes_yes": 8,
"binding_votes_no": 1,
"binding_votes_abstain": 0,
}
body_lines = list(tabulate._vote_resolution_body_votes({}, summary))
assert body_lines[2] == "Of these binding votes, 8 were +1, 1 was -1, and 0 were 0."
def test_vote_resolution_body_votes_uses_formal_label() -> None:
summary = {
"binding_votes": 3,
"binding_votes_yes": 3,
"binding_votes_no": 0,
"binding_votes_abstain": 0,
}
body_lines = list(tabulate._vote_resolution_body_votes({}, summary, "Formal", "Informal"))
assert body_lines[0] == "There were 3 formal votes."
assert body_lines[2] == "Of these formal votes, 3 were +1, 0 were -1, and 0 were 0."
def test_vote_result_subject_formats_result_before_vote() -> None:
release = SimpleNamespace(project=SimpleNamespace(display_name="Project"), version="1.0.0")
assert tabulate.vote_result_subject(release, "passed") == "[RESULT] [VOTE] Release Project 1.0.0 PASSED"
@pytest.mark.asyncio
async def test_votes_excludes_receipts_by_rfc_message_id_only(monkeypatch: pytest.MonkeyPatch) -> None:
async def _thread_messages(
_thread_id: str, *, strict: bool = False, source: bool = False
) -> AsyncIterator[tuple[str, dict[str, object]]]:
del strict
assert source is True
yield (
"archive-doc-receipt",
_vote_message(
from_raw="Receipt Voter <receipt@example.com>",
message_id="<receipt-mid@apache.org>",
archive_mid="archive-receipt",
epoch="1",
),
)
yield (
"archive-doc-keep",
_vote_message(
from_raw="Other Voter <other@example.com>",
message_id="<other-mid@apache.org>",
archive_mid="archive-other",
epoch="2",
),
)
yield (
"archive-doc-mid-only",
_vote_message(
from_raw="Archive Mid Voter <midonly@example.com>",
message_id="<different-mid@apache.org>",
archive_mid="receipt-mid@apache.org",
epoch="3",
),
)
monkeypatch.setattr(tabulate.config, "is_dev_environment", lambda: False)
lookup = tabulate.cache.EmailUidLookup(
{
tabulate.cache._email_uid_hash("receipt@example.com"): "receipt-voter",
tabulate.cache._email_uid_hash("other@example.com"): "other-voter",
tabulate.cache._email_uid_hash("midonly@example.com"): "mid-only-voter",
}
)
monkeypatch.setattr(tabulate.cache, "email_uid_view_or_live", mock.AsyncMock(return_value=lookup))
monkeypatch.setattr(tabulate.util, "thread_messages", _thread_messages)
_start_unixtime, tabulated_votes = await tabulate.votes(
None,
"0123456789abcdef0123456789abcdef",
excluded_message_ids={"receipt-mid@apache.org"},
)
assert "receipt-voter" not in tabulated_votes
assert tabulated_votes["other-voter"].vote == models_tabulate.Vote.YES
assert tabulated_votes["mid-only-voter"].asf_eid == "receipt-mid@apache.org"
def _make_release(*, committee: SimpleNamespace, podling_thread_id: str | None) -> SimpleNamespace:
return SimpleNamespace(
project=SimpleNamespace(committee=committee),
podling_thread_id=podling_thread_id,
)
@contextlib.asynccontextmanager
async def _mock_db_session(data: mock.MagicMock):
yield data
def _unexpected_db_session() -> None:
raise AssertionError("db.session should not be called")
def _vote_message(
*,
from_raw: str,
message_id: str,
archive_mid: str,
epoch: str,
) -> dict[str, object]:
return {
"from_raw": from_raw,
"list_raw": "dev.project.apache.org",
"cc": "",
"epoch": epoch,
"subject": "Re: [VOTE] Release project 1.0.0",
"body": "+1\n",
"message-id": message_id,
"mid": archive_mid,
"date": "2026-01-01T00:00:00Z",
"id": f"doc-{epoch}",
}