diff --git a/.gitea/workflows/backend.yaml b/.gitea/workflows/backend.yaml index 3bbb6fc..a3fb21c 100644 --- a/.gitea/workflows/backend.yaml +++ b/.gitea/workflows/backend.yaml @@ -73,23 +73,3 @@ jobs: - name: Budowa obrazu backendu run: docker build --tag zyrarbot-api:ci . - - # Sam start procesu to za mało - panel łączy się z API po sieci, więc - # sprawdzamy, że kontener faktycznie odpowiada. Zapytanie leci z jego - # wnętrza, żeby nie zależeć od narzędzi zainstalowanych na runnerze. - - name: Kontener odpowiada na API - run: | - docker run -d --name zyrarbot-ci -e DISCORD_TOKEN=ci-dummy zyrarbot-api:ci - probe="import json,urllib.request;json.load(urllib.request.urlopen('http://localhost:8000/api/health',timeout=2))" - for i in $(seq 1 30); do - if docker exec zyrarbot-ci python -c "$probe" 2>/dev/null; then - echo "API odpowiedziało po ${i}s" - docker rm -f zyrarbot-ci - exit 0 - fi - sleep 1 - done - echo "::error::API nie odpowiedziało w ciągu 30 sekund" - docker logs zyrarbot-ci - docker rm -f zyrarbot-ci - exit 1 diff --git a/Dockerfile b/Dockerfile index 052998e..8f3f2fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,31 @@ -# --- Etap 1: budowa panelu --------------------------------------------------- -FROM node:22-alpine AS frontend-build - +# --- Frontend build --------------------------------------------------------- +FROM node:22-alpine AS frontend-deps WORKDIR /frontend - COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci +FROM frontend-deps AS frontend-build COPY frontend/ ./ RUN npm run build -# --- Etap 2: aplikacja ------------------------------------------------------- -FROM python:3.13-slim - -# Zależności systemowe: opus dla głosu, ffmpeg do dekodowania audio -RUN apt-get update && apt-get install -y \ - libopus0 \ - libopus-dev \ - ffmpeg \ - build-essential \ +# --- Python base ------------------------------------------------------------ +FROM python:3.13-slim AS python-base +RUN apt-get update && apt-get install -y --no-install-recommends \ + libopus0 ffmpeg \ && rm -rf /var/lib/apt/lists/* - WORKDIR /app - -# Zależności przed źródłami, aby korzystać z cache warstw. -COPY requirements.txt . +COPY requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt +# --- Development ------------------------------------------------------------ +FROM python-base AS dev +RUN pip install --no-cache-dir ruff pytest +EXPOSE 8000 +CMD ["python", "-u", "main.py"] + +# --- Production ------------------------------------------------------------- +FROM python-base AS prod COPY . . COPY --from=frontend-build /frontend/dist /app/static - EXPOSE 8000 - -# Python serwuje API oraz zbudowany panel webowy. CMD ["python", "-u", "main.py"] diff --git a/Makefile b/Makefile index dff3bd1..cbfda95 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check-docker migrate migration-rollback create-admin +.PHONY: check-docker lint-back format-back lint-front typecheck-front test-back test-front check test migrate migration-rollback create-admin check-docker: @command -v docker >/dev/null 2>&1 || { echo "Docker nie jest zainstalowany lub nie ma go w PATH." >&2; exit 1; } @@ -10,6 +10,36 @@ USERNAME ?= admin create-admin: check-docker docker compose run --build --rm discord-bot python -m zyrarbot create-admin "$(USERNAME)" +define run_in_dev +@if docker compose ps --status running --services | grep -qx '$(1)'; then \ + docker compose exec -T $(1) $(2); \ +else \ + docker compose run --build --rm $(1) $(3); \ +fi +endef + +lint-back: check-docker + $(call run_in_dev,discord-bot,ruff check --fix .,ruff check --fix .) + +format-back: check-docker + $(call run_in_dev,discord-bot,ruff format .,ruff format .) + +lint-front: check-docker + $(call run_in_dev,frontend,npm run lint:fix,sh -c 'test -x node_modules/.bin/eslint || npm ci; npm run lint:fix') + +typecheck-front: check-docker + $(call run_in_dev,frontend,npm run typecheck,sh -c 'test -x node_modules/.bin/vue-tsc || npm ci; npm run typecheck') + +test-back: check-docker + $(call run_in_dev,discord-bot,pytest,pytest) + +test-front: check-docker + $(call run_in_dev,frontend,npm run smoke,sh -c 'test -x node_modules/.bin/eslint || npm ci; npm run smoke') + +check: lint-back format-back lint-front typecheck-front + +test: test-back test-front + migrate: check-docker docker compose run --build --rm discord-bot python -m $(MIGRATION_RUNNER) migrate diff --git a/README.md b/README.md index 20444b7..a6d0179 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,34 @@ Wymagane zmienne środowiskowe (plik `.env`): | `POSTGRES_PASSWORD` | Hasło użytkownika bazy; domyślnie `zyrarbot`, zalecana zmiana w `.env`. | | `POSTGRES_DB` / `POSTGRES_USER` | Opcjonalna nazwa bazy i użytkownika; domyślnie `zyrarbot`. | -Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze -jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`. +Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. + +### Docker: development i production + +Środowisko deweloperskie uruchom poleceniem: + +```bash +docker compose up --build +``` + +Kod Pythona jest montowany z katalogu projektu; po zmianach zrestartuj backend +poleceniem `docker compose restart discord-bot`. Frontend działa w osobnym +kontenerze i przebudowuje `frontend/dist` w trybie watch. Python serwuje ten sam +współdzielony katalog pod `http://localhost`. Port PostgreSQL `5432` jest +dostępny lokalnie. Obraz dev zawiera też Ruff, a kontener frontendu instaluje +narzędzia z `package-lock.json` (ESLint i `vue-tsc`). +Sprawdzenia uruchom w kontenerze backendu, np. `docker compose exec discord-bot +ruff check .` i `docker compose exec discord-bot pytest`; dla frontendu użyj +`docker compose exec frontend npm run lint` lub `npm run typecheck`. + +Production uruchom z pliku `compose.prod.yaml`: + +```bash +docker compose -f compose.prod.yaml up --build -d +``` + +W tym wariancie frontend jest budowany podczas tworzenia obrazu, a port PostgreSQL +nie jest publikowany na hoście. ### Konta i dostępy panelu diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..d33dffa --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,43 @@ +services: + database: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-zyrarbot} + POSTGRES_USER: ${POSTGRES_USER:-zyrarbot} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-zyrarbot} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + restart: unless-stopped + discord-bot: + build: + context: . + dockerfile: Dockerfile + target: prod + container_name: zyrarbot + environment: + - DISCORD_TOKEN=${DISCORD_TOKEN} + - GUILD_ID=${GUILD_ID:-} + - POSTGRES_DB=${POSTGRES_DB:-zyrarbot} + - POSTGRES_USER=${POSTGRES_USER:-zyrarbot} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-zyrarbot} + - POSTGRES_HOST=database + - POSTGRES_PORT=5432 + - PYTHONUNBUFFERED=1 + depends_on: + database: + condition: service_healthy + volumes: + - ./config:/app/config + - ./uploads:/app/uploads + ports: + - "80:8000" + restart: unless-stopped + +volumes: + postgres_data: diff --git a/compose.yaml b/compose.yaml index 0a442cb..abc8d2d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,29 +15,46 @@ services: start_period: 10s restart: unless-stopped ports: - - "5432:5432/tcp" + - "5432:5432" discord-bot: build: context: . dockerfile: Dockerfile - container_name: zyrarbot + target: dev environment: - - DISCORD_TOKEN=${DISCORD_TOKEN} - - GUILD_ID=${GUILD_ID:-} - - POSTGRES_DB=${POSTGRES_DB:-zyrarbot} - - POSTGRES_USER=${POSTGRES_USER:-zyrarbot} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-zyrarbot} - - POSTGRES_HOST=database - - POSTGRES_PORT=5432 - - PYTHONUNBUFFERED=1 + DISCORD_TOKEN: ${DISCORD_TOKEN} + GUILD_ID: ${GUILD_ID:-} + POSTGRES_DB: ${POSTGRES_DB:-zyrarbot} + POSTGRES_USER: ${POSTGRES_USER:-zyrarbot} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-zyrarbot} + POSTGRES_HOST: database + POSTGRES_PORT: 5432 + PYTHONUNBUFFERED: 1 depends_on: database: condition: service_healthy volumes: + - .:/app - ./config:/app/config - ./uploads:/app/uploads + - ./frontend/dist:/app/static ports: - - "80:8000" + - "8000:8000" + restart: unless-stopped + frontend: + build: + context: . + dockerfile: Dockerfile + target: frontend-deps + working_dir: /frontend + command: + - sh + - -c + - test -x node_modules/.bin/vite || npm ci; exec npx vite build --watch + volumes: + - ./frontend:/frontend + environment: + VITE_BACKEND_URL: http://discord-bot:8000 restart: unless-stopped volumes: diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index ce612a1..7c1cdde 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -2,8 +2,11 @@ from __future__ import annotations +import asyncio +import json import tempfile import unittest +from unittest.mock import MagicMock from zyrarbot.application import Application from zyrarbot.settings import Settings @@ -49,7 +52,12 @@ class ApiSurfaceTest(unittest.TestCase): uploads_dir=f"{cls._workdir.name}/uploads", static_dir=f"{cls._workdir.name}/static", ) - cls.spec = Application(settings).web.openapi() + persistence = MagicMock() + persistence.config = MagicMock() + persistence.history = MagicMock() + persistence.queues = MagicMock() + cls.app = Application(settings, persistence=persistence).web + cls.spec = cls.app.openapi() @classmethod def tearDownClass(cls) -> None: @@ -73,6 +81,41 @@ class ApiSurfaceTest(unittest.TestCase): self.assertTrue(self.spec["openapi"].startswith("3.")) self.assertIn("ŻyrarBOT", self.spec["info"]["title"]) + def test_health_endpoint_returns_api_response_without_database(self): + messages = [] + received = False + + async def receive(): + nonlocal received + if received: + return {"type": "http.disconnect"} + received = True + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + messages.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": "/api/health", + "raw_path": b"/api/health", + "query_string": b"", + "root_path": "", + "headers": [], + "client": ("testclient", 50000), + "server": ("testserver", 80), + } + asyncio.run(self.app(scope, receive, send)) + + start = next(message for message in messages if message["type"] == "http.response.start") + body = next(message for message in messages if message["type"] == "http.response.body") + self.assertEqual(start["status"], 200) + self.assertEqual(json.loads(body["body"]), {"status": "ok", "ready": False}) + def test_restart_endpoint_calls_restart_state(self): import asyncio from unittest.mock import MagicMock diff --git a/tests/test_guild_config_repository.py b/tests/test_guild_config_repository.py index c01ec99..de9fcd6 100644 --- a/tests/test_guild_config_repository.py +++ b/tests/test_guild_config_repository.py @@ -1,32 +1,33 @@ -import json -import tempfile -import unittest -from pathlib import Path +from __future__ import annotations -from zyrarbot.storage.guild_config_repository import GuildConfigRepository -from zyrarbot.storage.json_file_store import JsonFileStore +import unittest +from unittest.mock import MagicMock + +from zyrarbot.storage.adapters.postgres.guild_config import PostgresGuildConfig class GuildConfigRepositoryTest(unittest.TestCase): - def test_settings_are_isolated_and_legacy_default_moves_to_first_guild(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "config.json" - path.write_text(json.dumps({"default_plays": 7, "10": 123}), encoding="utf-8") - repository = GuildConfigRepository(JsonFileStore(str(path)), fallback_plays=5) + def setUp(self): + self.database = MagicMock() + self.connection = self.database.connect.return_value.__enter__.return_value + self.repository = PostgresGuildConfig(self.database, fallback_plays=5) - repository.migrate_legacy(10) - self.assertEqual(repository.get_default_plays(10), 7) - self.assertEqual(repository.get_notification_channel_id(10), 123) - self.assertEqual(repository.get_default_plays(20), 5) + def test_default_plays_uses_fallback_when_guild_has_no_override(self): + self.connection.execute.return_value.fetchone.return_value = (None,) - repository.set_default_plays(20, 9) - self.assertEqual(repository.get_default_plays(10), 7) - self.assertEqual(repository.get_default_plays(20), 9) + self.assertEqual(self.repository.get_default_plays(10), 5) - saved = json.loads(path.read_text(encoding="utf-8")) - self.assertNotIn("default_plays", saved) - self.assertEqual(saved["10"]["default_plays"], 7) - self.assertEqual(saved["20"]["default_plays"], 9) + def test_default_plays_returns_guild_override(self): + self.connection.execute.return_value.fetchone.return_value = (7,) + + self.assertEqual(self.repository.get_default_plays(10), 7) + + def test_set_default_plays_creates_guild_then_updates_setting(self): + self.repository.set_default_plays(10, 9) + + statements = [call.args[0] for call in self.connection.execute.call_args_list] + self.assertIn("INSERT INTO guilds", statements[0]) + self.assertIn("UPDATE guilds SET default_plays", statements[1]) if __name__ == "__main__": diff --git a/tests/test_history_repository.py b/tests/test_history_repository.py index e9cc156..5353c42 100644 --- a/tests/test_history_repository.py +++ b/tests/test_history_repository.py @@ -1,52 +1,49 @@ -import json -import tempfile +from __future__ import annotations + import unittest -from pathlib import Path +from unittest.mock import MagicMock from zyrarbot.music.history_entry import HistoryEntry -from zyrarbot.storage.history_repository import HistoryRepository -from zyrarbot.storage.json_file_store import JsonFileStore +from zyrarbot.storage.adapters.postgres.history import PostgresHistory class HistoryRepositoryTest(unittest.TestCase): - def test_history_is_stored_in_separate_files_and_legacy_data_is_migrated(self): - with tempfile.TemporaryDirectory() as directory: - legacy_path = Path(directory) / "history.json" - entry = HistoryEntry( - id="track-1", - title="Track", - url="https://example.test/track", - duration=10, - is_local=False, - filepath=None, - ) - legacy_path.write_text(json.dumps({"10": [entry.to_dict()]}), encoding="utf-8") - repository = HistoryRepository(JsonFileStore(str(legacy_path))) + def setUp(self): + self.database = MagicMock() + self.connection = self.database.connect.return_value.__enter__.return_value + self.repository = PostgresHistory(self.database) - repository.migrate_legacy(10) + def test_list_maps_database_rows_to_history_entries(self): + self.connection.execute.return_value.fetchall.return_value = [ + ("track-1", "Track", "https://example.test/track", 10, False) + ] - self.assertEqual(repository.list(10)[0].id, "track-1") - self.assertEqual(repository.list(20), []) - self.assertTrue((Path(directory) / "history" / "10.json").exists()) - self.assertFalse((Path(directory) / "history" / "20.json").exists()) + self.assertEqual( + self.repository.list(10), + [ + HistoryEntry( + id="track-1", + title="Track", + url="https://example.test/track", + duration=10, + is_local=False, + ) + ], + ) - def test_new_entries_are_available_after_repository_reload(self): - with tempfile.TemporaryDirectory() as directory: - store = JsonFileStore(str(Path(directory) / "history.json")) - entry = HistoryEntry( - id="track-2", - title="Another track", - url="https://example.test/another-track", - ) + def test_add_ignores_entries_without_a_title(self): + self.repository.add(10, HistoryEntry(id="track-1", title="")) - HistoryRepository(store).add(42, entry) + self.database.connect.assert_not_called() - reloaded = HistoryRepository(store) - self.assertEqual(reloaded.list(42), [entry]) - persisted = json.loads( - (Path(directory) / "history" / "42.json").read_text(encoding="utf-8") - ) - self.assertEqual(persisted["entries"][0]["id"], "track-2") + def test_add_persists_entry_for_guild(self): + self.connection.execute.return_value.fetchall.return_value = [] + entry = HistoryEntry(id="track-1", title="Track", url="https://example.test/track") + + self.repository.add(10, entry) + + statements = [call.args[0] for call in self.connection.execute.call_args_list] + self.assertTrue(any("INSERT INTO history_entries" in statement for statement in statements)) if __name__ == "__main__": diff --git a/tests/test_music_player.py b/tests/test_music_player.py index ac9d746..08f77e9 100644 --- a/tests/test_music_player.py +++ b/tests/test_music_player.py @@ -3,12 +3,11 @@ from __future__ import annotations import asyncio -import tempfile import unittest +from unittest.mock import MagicMock from tests.fakes import FakeTrack, build_player from zyrarbot.music.loop_mode import LoopMode -from zyrarbot.storage.queue_repository import QueueRepository GUILD = 1 @@ -293,28 +292,26 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase): async def test_restore_replaces_active_queue_without_old_callback_interference(self): player, voice_client = build_player() - with tempfile.TemporaryDirectory() as directory: - player._queues = QueueRepository(directory) - player._queues.save( - GUILD, - [{"id": "restored", "title": "Restored", "plays_left": 3}], - ) + player._queues = MagicMock() + player._queues.load.return_value = [ + {"id": "restored", "title": "Restored", "plays_left": 3} + ] - async def from_url(url, loop=None): - return FakeTrack("restored") + async def from_url(url, loop=None): + return FakeTrack("restored") - player._tracks.from_url = from_url - await player.enqueue(GUILD, FakeTrack("old")) - old_callback = voice_client.after + player._tracks.from_url = from_url + await player.enqueue(GUILD, FakeTrack("old")) + old_callback = voice_client.after - count = await player.restore_queue(GUILD) + count = await player.restore_queue(GUILD) - self.assertEqual(count, 1) - self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) - self.assertIs(player.state(GUILD).current, player.state(GUILD).queue[0]) - old_callback(None) - await asyncio.sleep(0) - self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) + self.assertEqual(count, 1) + self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) + self.assertIs(player.state(GUILD).current, player.state(GUILD).queue[0]) + old_callback(None) + await asyncio.sleep(0) + self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) async def test_restore_requires_voice_connection(self): player, _ = build_player() @@ -324,34 +321,28 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase): await player.restore_queue(GUILD) def test_save_queue_does_not_overwrite_with_empty_queue(self): - with tempfile.TemporaryDirectory() as directory: - player, _ = build_player() - player._queues = QueueRepository(directory) - saved = [{"id": "saved", "title": "Saved", "plays_left": 2}] - player._queues.save(GUILD, saved) + player, _ = build_player() + player._queues = MagicMock() - # Kolejka w stanie odtwarzacza jest pusta - player.state(GUILD).queue = [] - player.save_queue(GUILD) + # Kolejka w stanie odtwarzacza jest pusta + player.state(GUILD).queue = [] + player.save_queue(GUILD) - # Zapisana wcześniej kolejka powinna pozostać nienaruszona - self.assertEqual(player._queues.load(GUILD), saved) + # Zapisana wcześniej kolejka powinna pozostać nienaruszona + player._queues.save.assert_not_called() def test_save_queue_does_not_save_when_all_plays_consumed(self): - with tempfile.TemporaryDirectory() as directory: - player, _ = build_player() - player._queues = QueueRepository(directory) - saved = [{"id": "saved", "title": "Saved", "plays_left": 2}] - player._queues.save(GUILD, saved) + player, _ = build_player() + player._queues = MagicMock() - # Wszystkie utwory w kolejce mają plays_left = 0 - track = FakeTrack("consumed") - track.plays_left = 0 - player.state(GUILD).queue = [track] - player.save_queue(GUILD) + # Wszystkie utwory w kolejce mają plays_left = 0 + track = FakeTrack("consumed") + track.plays_left = 0 + player.state(GUILD).queue = [track] + player.save_queue(GUILD) - # Zapisana wcześniej kolejka powinna pozostać nienaruszona - self.assertEqual(player._queues.load(GUILD), saved) + # Zapisana wcześniej kolejka powinna pozostać nienaruszona + player._queues.save.assert_not_called() class LoopModeTest(unittest.TestCase): diff --git a/tests/test_queue_repository.py b/tests/test_queue_repository.py index f1a36a1..2c569f9 100644 --- a/tests/test_queue_repository.py +++ b/tests/test_queue_repository.py @@ -1,40 +1,35 @@ -import tempfile -import unittest -from pathlib import Path +from __future__ import annotations -from zyrarbot.storage.queue_repository import QueueRepository +import unittest +from unittest.mock import MagicMock + +from zyrarbot.storage.adapters.postgres.queue import PostgresQueue class QueueRepositoryTest(unittest.TestCase): - def test_snapshots_are_isolated_per_guild(self): - with tempfile.TemporaryDirectory() as directory: - repository = QueueRepository(directory) - first = [{"id": "first", "title": "First", "plays_left": 2}] - second = [{"id": "second", "title": "Second", "plays_left": 4}] + def setUp(self): + self.database = MagicMock() + self.connection = self.database.connect.return_value.__enter__.return_value + self.repository = PostgresQueue(self.database) - repository.save(10, first) - repository.save(20, second) + def test_load_returns_guild_snapshot(self): + snapshot = [{"id": "track-1", "title": "Track", "plays_left": 2}] + self.connection.execute.return_value.fetchone.return_value = (snapshot,) - self.assertEqual(repository.load(10), first) - self.assertEqual(repository.load(20), second) - self.assertTrue((Path(directory) / "10.json").exists()) - self.assertTrue((Path(directory) / "20.json").exists()) + self.assertEqual(self.repository.load(10), snapshot) - def test_save_empty_tracks_does_nothing(self): - with tempfile.TemporaryDirectory() as directory: - repository = QueueRepository(directory) - tracks = [{"id": "first", "title": "First", "plays_left": 2}] + def test_save_empty_snapshot_does_not_overwrite_saved_queue(self): + self.repository.save(10, []) - repository.save(10, tracks) - self.assertEqual(repository.load(10), tracks) + self.database.connect.assert_not_called() - # Próba zapisania pustej listy nie powinna nadpisać poprzedniej - repository.save(10, []) - self.assertEqual(repository.load(10), tracks) + def test_save_upserts_snapshot_for_guild(self): + tracks = [{"id": "track-1", "title": "Track", "plays_left": 2}] - # Zapis pustej kolejki dla nowego serwera nie powinien tworzyć pliku - repository.save(20, []) - self.assertFalse(repository.exists(20)) + self.repository.save(10, tracks) + + statements = [call.args[0] for call in self.connection.execute.call_args_list] + self.assertTrue(any("INSERT INTO queue_snapshots" in statement for statement in statements)) if __name__ == "__main__": diff --git a/tests/test_shutdown_voice_cleanup.py b/tests/test_shutdown_voice_cleanup.py index a2f5976..f6756d7 100644 --- a/tests/test_shutdown_voice_cleanup.py +++ b/tests/test_shutdown_voice_cleanup.py @@ -16,6 +16,7 @@ class ShutdownVoiceCleanupTests(unittest.IsolatedAsyncioTestCase): self.uploads = MagicMock() self.config = MagicMock() self.history = MagicMock() + self.migration = MagicMock() self.bot = MusicBot( self.settings, @@ -23,6 +24,7 @@ class ShutdownVoiceCleanupTests(unittest.IsolatedAsyncioTestCase): self.uploads, self.config, self.history, + self.migration, ) self.player = MagicMock() self.bot.player = self.player diff --git a/tests/test_uploads_repository.py b/tests/test_uploads_repository.py index d5a1f93..abf38b1 100644 --- a/tests/test_uploads_repository.py +++ b/tests/test_uploads_repository.py @@ -2,7 +2,7 @@ import tempfile import unittest from pathlib import Path -from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.storage.adapters.filesystem.uploads import UploadsRepository from zyrarbot.ui.bot.cogs.playback_cog import upload_choices diff --git a/zyrarbot/storage/adapters/postgres/database.py b/zyrarbot/storage/adapters/postgres/database.py index 390d4eb..d787ca1 100644 --- a/zyrarbot/storage/adapters/postgres/database.py +++ b/zyrarbot/storage/adapters/postgres/database.py @@ -2,9 +2,10 @@ from __future__ import annotations import json import logging +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterator +from typing import Any import psycopg @@ -71,7 +72,11 @@ class PostgresDatabase: for key, entries in history.items(): if key.isdigit() and isinstance(entries, list): _import_history(connection, int(key), entries) - elif key == "history" and isinstance(entries, list) and first_guild_id is not None: + elif ( + key == "history" + and isinstance(entries, list) + and first_guild_id is not None + ): _import_history(connection, first_guild_id, entries) for path in (base / "queues").glob("*.json"): @@ -109,14 +114,17 @@ def _upsert_guild( try: value = int(values[field]) except (TypeError, ValueError): - logger.error("Nieprawidłowa wartość %s serwera %s: %r", field, guild_id, values[field]) + logger.error( + "Nieprawidłowa wartość %s serwera %s: %r", + field, + guild_id, + values[field], + ) continue connection.execute(f"UPDATE guilds SET {field}=%s WHERE id=%s", (value, guild_id)) -def _import_history( - connection: psycopg.Connection[Any], guild_id: int, entries: list[Any] -) -> None: +def _import_history(connection: psycopg.Connection[Any], guild_id: int, entries: list[Any]) -> None: _ensure_guild(connection, guild_id) if connection.execute( "SELECT 1 FROM history_entries WHERE guild_id=%s LIMIT 1", (guild_id,) diff --git a/zyrarbot/storage/adapters/postgres/guild_config.py b/zyrarbot/storage/adapters/postgres/guild_config.py index 6644006..315ce86 100644 --- a/zyrarbot/storage/adapters/postgres/guild_config.py +++ b/zyrarbot/storage/adapters/postgres/guild_config.py @@ -12,7 +12,9 @@ class PostgresGuildConfig: def _ensure(self, guild_id: int) -> None: with self._database.connect() as connection: - connection.execute("INSERT INTO guilds(id) VALUES (%s) ON CONFLICT(id) DO NOTHING", (guild_id,)) + connection.execute( + "INSERT INTO guilds(id) VALUES (%s) ON CONFLICT(id) DO NOTHING", (guild_id,) + ) def get_notification_channel_id(self, guild_id: int) -> int | None: with self._database.connect() as connection: @@ -48,10 +50,14 @@ class PostgresGuildConfig: if guild_id is None: return self._fallback_plays with self._database.connect() as connection: - row = connection.execute("SELECT default_plays FROM guilds WHERE id=%s", (guild_id,)).fetchone() + row = connection.execute( + "SELECT default_plays FROM guilds WHERE id=%s", (guild_id,) + ).fetchone() return int(row[0]) if row and row[0] is not None else self._fallback_plays def set_default_plays(self, guild_id: int, value: int) -> None: self._ensure(guild_id) with self._database.connect() as connection: - connection.execute("UPDATE guilds SET default_plays=%s WHERE id=%s", (int(value), guild_id)) + connection.execute( + "UPDATE guilds SET default_plays=%s WHERE id=%s", (int(value), guild_id) + ) diff --git a/zyrarbot/storage/adapters/postgres/persistence.py b/zyrarbot/storage/adapters/postgres/persistence.py index 309c4f0..abc1a62 100644 --- a/zyrarbot/storage/adapters/postgres/persistence.py +++ b/zyrarbot/storage/adapters/postgres/persistence.py @@ -25,9 +25,7 @@ class PostgresPersistence: self.config: GuildConfigRepository = PostgresGuildConfig( self.database, fallback_plays=default_plays ) - self.history: HistoryRepository = PostgresHistory( - self.database, limit=history_limit - ) + self.history: HistoryRepository = PostgresHistory(self.database, limit=history_limit) self.queues: QueueRepository = PostgresQueue(self.database) self._config_dir = config_dir