Add v2 support for independent GusVoice connections to one AWG server

This commit is contained in:
wenil committed 2026-10-30 15:38:31 +03:00
1 parent 38c6a1a682
commit 0e62fe20ed
9 files changed
+759 -148

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
import asyncio
import contextlib
import io
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT), str(ROOT / "vendor")]
from awg_gus import manage
from awg_gus.bot import Bot
from awg_gus.core import State
from awg_gus.main import configure, load_config, save_config
from awg_gus.profiles import Layout, assert_unique_bot, profile_name
from test_bot import FakeAwg, FakeDelivery, OWNER, OTHER, callback, message
BOT_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
BOT_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
def config(layout, name, bot=BOT_A):
return dict(url=f"https://{name}.example.test", token="gvb_test_" + bot,
bot_id=bot, owners=[OWNER], awg2=str(layout.root / "bin/awg2"),
state_dir=str(layout.state(name)))
class FakeSystemd:
def __init__(self):
self.calls = []
self.active = {Layout.unit("default")}
self.enabled = set(self.active)
self.fail_restart = False
def __call__(self, *args, check=True):
self.calls.append(args)
action, unit = args[0], args[-1]
rc = 0
if action == "is-active":
rc = 0 if unit in self.active else 3
elif action == "is-enabled":
rc = 0 if unit in self.enabled else 1
elif action == "stop":
self.active.discard(unit)
elif action == "disable":
self.enabled.discard(unit)
elif action == "enable":
self.enabled.add(unit)
elif action in {"start", "restart"}:
if action == "restart" and self.fail_restart:
raise RuntimeError("simulated startup failure")
self.active.add(unit)
return SimpleNamespace(returncode=rc, stdout="active" if rc == 0 else "inactive")
class ProfileTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.layout = Layout(Path(self.tmp.name))
save_config(self.layout.config("default"), config(self.layout, "default"))
def tearDown(self):
self.tmp.cleanup()
def test_legacy_paths_preserved_and_new_paths_isolated(self):
self.assertEqual(self.layout.names(), ["default"])
self.assertEqual(self.layout.config("default"), self.layout.root / "etc/awg-gus-bot.json")
self.assertNotEqual(self.layout.state("gus2"), self.layout.state("default"))
for name in ["../bad", "x/y", "bad;id", "a.service", "-name", "A", "a"*33]:
with self.subTest(name=name), self.assertRaises(ValueError):
profile_name(name)
def test_duplicate_bot_rejected_even_with_alias_url_and_rotated_token(self):
candidate = config(self.layout, "gus2")
candidate["token"] = "gvb_rotated"
with self.assertRaisesRegex(ValueError, "default"):
assert_unique_bot(self.layout.config("gus2"), candidate, self.layout)
def test_second_bot_and_existing_token_rotation_are_allowed(self):
candidate = config(self.layout, "gus2", BOT_B)
assert_unique_bot(self.layout.config("gus2"), candidate, self.layout)
original = config(self.layout, "default")
original["token"] = "gvb_rotated"
assert_unique_bot(self.layout.config("default"), original, self.layout)
def test_existing_profile_cannot_silently_switch_identity(self):
candidate = config(self.layout, "default", BOT_B)
with self.assertRaisesRegex(ValueError, "другой бот"):
assert_unique_bot(self.layout.config("default"), candidate, self.layout)
def test_duplicate_is_rejected_before_pairing_or_polling(self):
client = Mock()
client.get_me.return_value = dict(id=BOT_A, api_version=3,
features=["commands", "keyboards", "parse_mode"])
client.get_webhook_info.return_value = {}
client.api_url.return_value = "https://alias.example.test"
args = SimpleNamespace(config=str(self.layout.config("gus2")), layout=self.layout)
with patch("awg_gus.main.sys.stdin.isatty", return_value=True), \
patch("builtins.input", return_value="https://alias.example.test"), \
patch("awg_gus.main.getpass.getpass", return_value="gvb_rotated"), \
patch("awg_gus.main.Client", return_value=client), \
patch("awg_gus.main.pair") as pair, contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, "default"):
configure(args)
pair.assert_not_called()
client.get_updates.assert_not_called()
self.assertFalse(self.layout.config("gus2").exists())
def test_add_starts_only_new_unit_and_preserves_legacy_config(self):
original = self.layout.config("default").read_bytes()
systemd = FakeSystemd()
def setup(args):
save_config(args.config, config(self.layout, "gus2", BOT_B))
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.shutil.which", return_value=str(self.layout.root / "bin/awg2")), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock), \
patch("awg_gus.manage.time.sleep"), contextlib.redirect_stdout(io.StringIO()):
manage.setup_profile("gus2", layout=self.layout, setup_fn=setup, check_fn=AsyncMock())
self.assertEqual(self.layout.config("default").read_bytes(), original)
self.assertEqual(self.layout.names(), ["default", "gus2"])
self.assertEqual(systemd.active, {Layout.unit("default"), Layout.unit("gus2")})
self.assertFalse(any(c[-1] == Layout.unit("default") for c in systemd.calls))
template = (self.layout.root / "etc/systemd/system/awg-gus-bot@.service").read_text()
self.assertIn("/instances/%i.json", template)
self.assertIn("Restart=on-failure", template)
def test_failed_add_rolls_back_only_new_profile(self):
original = self.layout.config("default").read_bytes()
systemd = FakeSystemd()
systemd.fail_restart = True
def setup(args):
save_config(args.config, config(self.layout, "gus2", BOT_B))
s = State(Path(args.state_dir) / "state.sqlite3", "new")
s.set("offset", 22)
s.db.close()
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.shutil.which", return_value=str(self.layout.root / "bin/awg2")), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock):
with self.assertRaises(RuntimeError):
manage.setup_profile("gus2", layout=self.layout, setup_fn=setup, check_fn=AsyncMock())
self.assertEqual(self.layout.config("default").read_bytes(), original)
self.assertFalse(self.layout.config("gus2").exists())
self.assertFalse((self.layout.state("gus2") / "state.sqlite3").exists())
self.assertEqual(systemd.active, {Layout.unit("default")})
self.assertNotIn(Layout.unit("gus2"), systemd.enabled)
def test_failed_reconfigure_restores_cursor_config_and_service(self):
original = self.layout.config("default").read_bytes()
database = self.layout.state("default") / "state.sqlite3"
s = State(database, "original")
s.set("offset", 91)
s.db.close()
systemd = FakeSystemd()
def setup(args):
changed = config(self.layout, "default")
changed["token"] = "gvb_changed"
save_config(args.config, changed)
s = State(database, "original")
s.set("offset", 999)
s.db.close()
raise ValueError("pairing failed")
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock):
with self.assertRaisesRegex(ValueError, "pairing failed"):
manage.setup_profile("default", reconfigure=True, layout=self.layout,
setup_fn=setup, check_fn=AsyncMock())
self.assertEqual(self.layout.config("default").read_bytes(), original)
s = State(database, "original")
self.assertEqual(s.get("offset"), 91)
s.db.close()
self.assertIn(Layout.unit("default"), systemd.active)
def test_check_continues_when_one_gus_is_unreachable(self):
save_config(self.layout.config("gus2"), config(self.layout, "gus2", BOT_B))
seen = []
async def fake_check(args):
seen.append(args.config)
if args.config == str(self.layout.config("default")):
raise OSError("offline")
with patch("awg_gus.manage.check", fake_check), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, "default"):
manage.dispatch("check", None, self.layout)
self.assertEqual(len(seen), 2)
def test_failed_database_snapshot_does_not_restore_partial_backup(self):
database = self.layout.state("default") / "state.sqlite3"
s = State(database, "original")
s.set("offset", 91)
s.db.close()
systemd = FakeSystemd()
def bad_copy(source, destination):
Path(destination).write_bytes(b"partial backup")
raise OSError("disk full")
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock), \
patch("awg_gus.manage.database_copy", side_effect=bad_copy) as copy:
with self.assertRaises(OSError):
manage.setup_profile("default", reconfigure=True, layout=self.layout)
self.assertEqual(copy.call_count, 1)
s = State(database, "original")
self.assertEqual(s.get("offset"), 91)
s.db.close()
self.assertIn(Layout.unit("default"), systemd.active)
class ParallelBotsTests(unittest.IsolatedAsyncioTestCase):
async def test_parallel_gus_updates_confirmations_and_owners_are_isolated(self):
with tempfile.TemporaryDirectory() as directory:
states = [State(Path(directory)/f"{i}.sqlite3", f"gus{i}") for i in range(2)]
api = FakeAwg()
deliveries = [FakeDelivery(), FakeDelivery()]
bots = [Bot({"owners": [owner]}, api, delivery, state)
for owner, delivery, state in zip([OWNER, OTHER], deliveries, states)]
async def feed(bot, event):
await bot.process(event)
await asyncio.gather(*bot.tasks.values())
try:
await asyncio.gather(feed(bots[0], callback(1, "delete:alice")),
feed(bots[1], message(1, "/menu", OTHER)))
token = next(iter(bots[0].confirmations))
await feed(bots[1], callback(2, "yes:"+token, OTHER))
self.assertNotIn(("client", "del", "alice"), api.calls)
await feed(bots[0], callback(2, "yes:"+token))
await feed(bots[1], message(3, "/clients", OTHER))
self.assertEqual(api.rows, []) # mutation visible from both connections
before = len(api.calls)
await feed(bots[1], callback(4, "conf:alice", OWNER))
self.assertEqual(len(api.calls), before)
self.assertEqual(states[0].get("offset"), 3)
self.assertEqual(states[1].get("offset"), 5)
finally:
for state in states:
state.db.close()
if __name__ == "__main__":
unittest.main()