Add v2 support for independent GusVoice connections to one AWG server
This commit is contained in:
1 parent
38c6a1a682
commit
0e62fe20ed
9 files changed
+759
-148
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user