GigaProjects

← Back to claude-usage-gnome

test_fetch_usage.py

import importlib.util
import pathlib
import sys
import unittest
import urllib.error
from datetime import datetime, timezone
from unittest import mock


PROJECT_DIRECTORY = pathlib.Path(__file__).parent.parent
HELPER_PATH = PROJECT_DIRECTORY / "fetch-usage.py"

MODULE_SPEC = importlib.util.spec_from_file_location("fetch_usage", HELPER_PATH)
fetch_usage = importlib.util.module_from_spec(MODULE_SPEC)
MODULE_SPEC.loader.exec_module(fetch_usage)


class UsageFormattingTests(unittest.TestCase):
    def setUp(self):
        self.now = datetime(2026, 8, 2, 10, 0, tzinfo=timezone.utc)

    def test_pace_arrow_points_up_when_usage_is_on_pace(self):
        arrow = fetch_usage.pace_arrow(
            used_percentage=60,
            reset_time_text="2026-08-02T12:00:00+00:00",
            total_seconds=5 * 60 * 60,
            now=self.now,
        )

        self.assertEqual(arrow, "↑")

    def test_pace_arrow_points_down_when_usage_is_ahead_of_pace(self):
        arrow = fetch_usage.pace_arrow(
            used_percentage=61,
            reset_time_text="2026-08-02T12:00:00+00:00",
            total_seconds=5 * 60 * 60,
            now=self.now,
        )

        self.assertEqual(arrow, "↓")

    def test_format_remaining_includes_hours_and_minutes(self):
        remaining = fetch_usage.format_remaining(
            "2026-08-02T11:02:59+00:00",
            self.now,
        )

        self.assertEqual(remaining, "1h02m")

    def test_format_remaining_returns_zero_after_reset(self):
        remaining = fetch_usage.format_remaining(
            "2026-08-02T09:59:00+00:00",
            self.now,
        )

        self.assertEqual(remaining, "0m")

    def test_format_usage_returns_both_limits(self):
        usage = {
            "five_hour": {
                "utilization": 20,
                "resets_at": "2026-08-02T12:00:00+00:00",
            },
            "seven_day": {"utilization": 31.4},
        }

        text = fetch_usage.format_usage(usage, self.now)

        self.assertEqual(text, "5h: 20%↑ 2h00m | 7d: 31%")

    def test_format_usage_handles_missing_five_hour_window(self):
        usage = {
            "five_hour": {"utilization": None, "resets_at": None},
            "seven_day": {"utilization": 31.4},
        }

        text = fetch_usage.format_usage(usage, self.now)

        self.assertEqual(text, "5h: -- | 7d: 31%")

    def test_format_usage_rejects_missing_api_fields(self):
        with self.assertRaises(KeyError):
            fetch_usage.format_usage({}, self.now)

    def test_format_usage_rejects_null_percentage(self):
        usage = {
            "five_hour": {"utilization": 20, "resets_at": None},
            "seven_day": {"utilization": None},
        }

        with self.assertRaises(TypeError):
            fetch_usage.format_usage(usage, self.now)


class CredentialTests(unittest.TestCase):
    def test_load_access_token_reads_claude_credentials(self):
        credentials = '{"claudeAiOauth": {"accessToken": "test-token"}}'

        with mock.patch("builtins.open", mock.mock_open(read_data=credentials)):
            access_token = fetch_usage.load_access_token()

        self.assertEqual(access_token, "test-token")

    def test_load_access_token_requests_login_when_file_is_missing(self):
        with mock.patch("builtins.open", side_effect=FileNotFoundError):
            with self.assertRaisesRegex(fetch_usage.DisplayError, "Claude: log in"):
                fetch_usage.load_access_token()

    def test_load_access_token_rejects_invalid_credentials(self):
        credentials = '{"claudeAiOauth": {}}'

        with mock.patch("builtins.open", mock.mock_open(read_data=credentials)):
            with self.assertRaisesRegex(fetch_usage.DisplayError, "Claude: auth error"):
                fetch_usage.load_access_token()


class FetchUsageTests(unittest.TestCase):
    @mock.patch.object(fetch_usage.urllib.request, "urlopen")
    def test_fetch_usage_parses_json_response(self, urlopen):
        response = mock.MagicMock()
        response.read.return_value = b'{"five_hour": {}, "seven_day": {}}'
        response.__enter__.return_value = response
        urlopen.return_value = response

        usage = fetch_usage.fetch_usage("test-token")

        self.assertEqual(usage, {"five_hour": {}, "seven_day": {}})

    @mock.patch.object(fetch_usage.urllib.request, "urlopen")
    def test_fetch_usage_requests_login_after_unauthorized_response(self, urlopen):
        urlopen.side_effect = urllib.error.HTTPError(
            fetch_usage.USAGE_URL,
            401,
            "Unauthorized",
            {},
            None,
        )

        with self.assertRaisesRegex(fetch_usage.DisplayError, "Claude: log in"):
            fetch_usage.fetch_usage("expired-token")

    @mock.patch.object(fetch_usage.urllib.request, "urlopen")
    def test_fetch_usage_reports_network_failure(self, urlopen):
        urlopen.side_effect = urllib.error.URLError("offline")

        with self.assertRaisesRegex(fetch_usage.DisplayError, "Claude: offline"):
            fetch_usage.fetch_usage("test-token")

    @mock.patch.object(fetch_usage.urllib.request, "urlopen")
    def test_fetch_usage_rejects_invalid_json(self, urlopen):
        response = mock.MagicMock()
        response.read.return_value = b"not json"
        response.__enter__.return_value = response
        urlopen.return_value = response

        with self.assertRaisesRegex(fetch_usage.DisplayError, "Claude: bad data"):
            fetch_usage.fetch_usage("test-token")


if __name__ == "__main__":
    unittest.main()

Run this code