"""Unit tests for weekly_activity.config — pure logic, no network.""" from __future__ import annotations import os import tempfile import unittest from pathlib import Path from weekly_activity.config import ( ActivityConfig, BtsSettings, ConfigError, GithubSettings, GitlabSettings, LaunchpadSettings, default_config_path, format_toml, load_config, save_config, ) def load_config_from_text(text: str) -> ActivityConfig: """Helper: parse TOML through the public loader without touching disk.""" with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "weekly-activity.toml" path.write_text(text, encoding="utf-8") return load_config(path) def sample_v2_config() -> ActivityConfig: return ActivityConfig( launchpad=[ LaunchpadSettings( username="lp-user", mode="credentials", credentials_file="/tmp/lp-creds.json", service="staging", name="work", ), LaunchpadSettings(username="anon-lp"), ], github=[ GithubSettings(username='gh"user', token="tok\\en"), GithubSettings(username="acme-jane", token_env="WORK_GH_TOKEN", name="work"), ], gitlab=[ GitlabSettings( username="gl-user", url="https://salsa.debian.org", token_env="SALSA_TOKEN", name="salsa", ) ], bts=[BtsSettings(email="dev@example.org")], ) class ConfigRoundTripTest(unittest.TestCase): def test_round_trip_preserves_all_accounts(self) -> None: config = sample_v2_config() with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "weekly-activity.toml" save_config(config, path) self.assertEqual(load_config(path), config) def test_save_writes_v2_version_header(self) -> None: text = format_toml(ActivityConfig(github=[GithubSettings(username="g")])) self.assertIn("version = 2", text) self.assertIn("[[github.accounts]]", text) def test_save_creates_owner_only_file(self) -> None: config = ActivityConfig(bts=[BtsSettings(email="x@example.org")]) with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "nested" / "cfg.toml" save_config(config, path) self.assertEqual(os.stat(path).st_mode & 0o777, 0o600) def test_empty_config_emits_header_only(self) -> None: text = format_toml(ActivityConfig()) self.assertEqual(load_config_from_text(text), ActivityConfig()) self.assertNotIn("[[", text) def test_empty_optionals_round_trip_to_defaults(self) -> None: config = ActivityConfig( github=[GithubSettings(username="octocat")], bts=[BtsSettings(email="a@b.c")], ) reparsed = load_config_from_text(format_toml(config)) self.assertEqual(reparsed.github, [GithubSettings(username="octocat")]) self.assertEqual(reparsed.bts, [BtsSettings(email="a@b.c")]) self.assertEqual(reparsed.launchpad, []) def test_escapes_quotes_backslashes_and_newlines(self) -> None: config = ActivityConfig(github=[GithubSettings(username='a"b\\c\nd')]) reparsed = load_config_from_text(format_toml(config)) self.assertEqual(reparsed.github[0].username, 'a"b\\c\nd') def test_load_does_not_rewrite_file(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "weekly-activity.toml" original = "[github]\nusername = 'octo'\n" path.write_text(original, encoding="utf-8") before = path.read_bytes() config = load_config(path) self.assertEqual(config.github, [GithubSettings(username="octo")]) self.assertEqual(path.read_bytes(), before) class ConfigLoadingTest(unittest.TestCase): def test_missing_file_raises_filenotfound(self) -> None: with self.assertRaises(FileNotFoundError): load_config(Path("/nonexistent/weekly-activity.toml")) def test_unknown_section_rejected(self) -> None: with self.assertRaises(ConfigError): load_config_from_text('version = 2\n\n[[gitea.accounts]]\nusername = "x"\n') def test_unknown_top_level_key_rejected(self) -> None: with self.assertRaises(ConfigError): load_config_from_text("version = 2\nsources = 3\n") def test_unknown_account_key_rejected(self) -> None: with self.assertRaises(ConfigError): load_config_from_text('[github]\naccounts = [{username = "x", tokken = "t"}]\n') def test_section_must_hold_only_accounts_table(self) -> None: with self.assertRaises(ConfigError): load_config_from_text('[version]\nwrong = "shape"\n') # wrong type below with self.assertRaises(ConfigError): load_config_from_text( 'version = 2\n\n[github]\nusername = "legacy"\n' # v2 without .accounts ) def test_accounts_must_be_list_of_tables(self) -> None: with self.assertRaises(ConfigError): load_config_from_text('version = 2\n\n[github]\naccounts = "nope"\n') with self.assertRaises(ConfigError): load_config_from_text("version = 2\n\n[gitlab]\naccounts = [42]\n") def test_non_string_value_rejected(self) -> None: with self.assertRaises(ConfigError): load_config_from_text("version = 2\n\n[[github.accounts]]\nusername = 42\n") def test_bad_launchpad_mode_rejected(self) -> None: with self.assertRaises(ConfigError): load_config_from_text( 'version = 2\n\n[[launchpad.accounts]]\nusername = "u"\nmode = "oauth2"\n' ) def test_bad_launchpad_service_rejected(self) -> None: with self.assertRaises(ConfigError): load_config_from_text( 'version = 2\n\n[[launchpad.accounts]]\nusername = "u"\nservice = "qa"\n' ) def test_v2_section_without_accounts_table_rejected(self) -> None: # A version-tagged file must use the [[kind.accounts]] shape. with self.assertRaises(ConfigError): load_config_from_text('version = 2\n\n[github]\nusername = "legacy"\n') def test_account_errors_carry_position(self) -> None: text = ( "version = 2\n" '\n[[launchpad.accounts]]\nusername = "ok"\n' '\n[[launchpad.accounts]]\nusername = "bad"\nservice = "oops"\n' ) with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "weekly-activity.toml" path.write_text(text, encoding="utf-8") with self.assertRaisesRegex(ConfigError, r"launchpad\.accounts\[1\]"): load_config(path) def test_enabled_sources_stable_order(self) -> None: config = ActivityConfig( bts=[BtsSettings(email="e")], github=[GithubSettings(username="g")], ) self.assertEqual(config.enabled_sources(), ["github", "bts"]) class ConfigMigrationTest(unittest.TestCase): V1_TEXT = """\ [launchpad] username = "lp-user" mode = "credentials" credentials_file = "~/creds.json" [github] username = "octo" token = "t0k" [gitlab] url = "https://salsa.debian.org" username = "gl" token_env = "SALSA_TOKEN" [bts] email = "dev@example.org" """ def test_v1_file_migrates_to_single_unnamed_accounts_in_memory(self) -> None: config = load_config_from_text(self.V1_TEXT) self.assertEqual(config.enabled_sources(), ["launchpad", "github", "gitlab", "bts"]) self.assertEqual( config.launchpad, [ LaunchpadSettings( username="lp-user", mode="credentials", credentials_file="~/creds.json", ) ], ) self.assertEqual(config.github, [GithubSettings(username="octo", token="t0k")]) self.assertEqual( config.gitlab, [ GitlabSettings( url="https://salsa.debian.org", username="gl", token_env="SALSA_TOKEN", ) ], ) self.assertEqual(config.bts, [BtsSettings(email="dev@example.org")]) def test_saving_migrated_config_persists_v2_shape(self) -> None: config = load_config_from_text(self.V1_TEXT) text = format_toml(config) self.assertIn("version = 2", text) self.assertEqual(load_config_from_text(text), config) def test_v1_unknown_keys_still_strict(self) -> None: with self.assertRaises(ConfigError): load_config_from_text('[github]\nusername = "u"\ntokken = "t"\n') def test_v2_shaped_file_without_version_rejected(self) -> None: with self.assertRaisesRegex(ConfigError, "version"): load_config_from_text('[[github.accounts]]\nusername = "u"\n') class ConfigPathTest(unittest.TestCase): def test_default_path_uses_xdg_or_home(self) -> None: old = os.environ.get("XDG_CONFIG_HOME") try: os.environ["XDG_CONFIG_HOME"] = "/custom/cfg" self.assertEqual(default_config_path(), Path("/custom/cfg/weekly-activity.toml")) del os.environ["XDG_CONFIG_HOME"] self.assertTrue(str(default_config_path()).endswith("/.config/weekly-activity.toml")) finally: if old is not None: os.environ["XDG_CONFIG_HOME"] = old if __name__ == "__main__": unittest.main()