Merge branch 'release/6.0' into feature/auth

# Conflicts:
#	Makefile
#	zyrarbot/storage/adapters/postgres/migrations/__init__.py
This commit is contained in:
mmacierzanka committed 2026-10-01 17:30:53 +02:00
commit dabb62782f
16 files changed
+330 -197

No files matched your search

-20
View File
@@ -73,23 +73,3 @@ jobs:
- name: Budowa obrazu backendu - name: Budowa obrazu backendu
run: docker build --tag zyrarbot-api:ci . 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
+16 -20
View File
@@ -1,35 +1,31 @@
# --- Etap 1: budowa panelu --------------------------------------------------- # --- Frontend build ---------------------------------------------------------
FROM node:22-alpine AS frontend-build FROM node:22-alpine AS frontend-deps
WORKDIR /frontend WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
FROM frontend-deps AS frontend-build
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
# --- Etap 2: aplikacja ------------------------------------------------------- # --- Python base ------------------------------------------------------------
FROM python:3.13-slim FROM python:3.13-slim AS python-base
RUN apt-get update && apt-get install -y --no-install-recommends \
# Zależności systemowe: opus dla głosu, ffmpeg do dekodowania audio libopus0 ffmpeg \
RUN apt-get update && apt-get install -y \
libopus0 \
libopus-dev \
ffmpeg \
build-essential \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY requirements.txt ./requirements.txt
# Zależności przed źródłami, aby korzystać z cache warstw.
COPY requirements.txt .
RUN pip install --no-cache-dir -r 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 . .
COPY --from=frontend-build /frontend/dist /app/static COPY --from=frontend-build /frontend/dist /app/static
EXPOSE 8000 EXPOSE 8000
# Python serwuje API oraz zbudowany panel webowy.
CMD ["python", "-u", "main.py"] CMD ["python", "-u", "main.py"]
+31 -1
View File
@@ -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: check-docker:
@command -v docker >/dev/null 2>&1 || { echo "Docker nie jest zainstalowany lub nie ma go w PATH." >&2; exit 1; } @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 create-admin: check-docker
docker compose run --build --rm discord-bot python -m zyrarbot create-admin "$(USERNAME)" 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 migrate: check-docker
docker compose run --build --rm discord-bot python -m $(MIGRATION_RUNNER) migrate docker compose run --build --rm discord-bot python -m $(MIGRATION_RUNNER) migrate
+28 -2
View File
@@ -19,8 +19,34 @@ Wymagane zmienne środowiskowe (plik `.env`):
| `POSTGRES_PASSWORD` | Hasło użytkownika bazy; domyślnie `zyrarbot`, zalecana zmiana w `.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`. | | `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 Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego.
jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
### 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 ### Konta i dostępy panelu
+43
View File
@@ -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:
+28 -11
View File
@@ -15,29 +15,46 @@ services:
start_period: 10s start_period: 10s
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5432:5432/tcp" - "5432:5432"
discord-bot: discord-bot:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: zyrarbot target: dev
environment: environment:
- DISCORD_TOKEN=${DISCORD_TOKEN} DISCORD_TOKEN: ${DISCORD_TOKEN}
- GUILD_ID=${GUILD_ID:-} GUILD_ID: ${GUILD_ID:-}
- POSTGRES_DB=${POSTGRES_DB:-zyrarbot} POSTGRES_DB: ${POSTGRES_DB:-zyrarbot}
- POSTGRES_USER=${POSTGRES_USER:-zyrarbot} POSTGRES_USER: ${POSTGRES_USER:-zyrarbot}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-zyrarbot} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-zyrarbot}
- POSTGRES_HOST=database POSTGRES_HOST: database
- POSTGRES_PORT=5432 POSTGRES_PORT: 5432
- PYTHONUNBUFFERED=1 PYTHONUNBUFFERED: 1
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
volumes: volumes:
- .:/app
- ./config:/app/config - ./config:/app/config
- ./uploads:/app/uploads - ./uploads:/app/uploads
- ./frontend/dist:/app/static
ports: 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 restart: unless-stopped
volumes: volumes:
+44 -1
View File
@@ -2,8 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import tempfile import tempfile
import unittest import unittest
from unittest.mock import MagicMock
from zyrarbot.application import Application from zyrarbot.application import Application
from zyrarbot.settings import Settings from zyrarbot.settings import Settings
@@ -49,7 +52,12 @@ class ApiSurfaceTest(unittest.TestCase):
uploads_dir=f"{cls._workdir.name}/uploads", uploads_dir=f"{cls._workdir.name}/uploads",
static_dir=f"{cls._workdir.name}/static", 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 @classmethod
def tearDownClass(cls) -> None: def tearDownClass(cls) -> None:
@@ -73,6 +81,41 @@ class ApiSurfaceTest(unittest.TestCase):
self.assertTrue(self.spec["openapi"].startswith("3.")) self.assertTrue(self.spec["openapi"].startswith("3."))
self.assertIn("ŻyrarBOT", self.spec["info"]["title"]) 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): def test_restart_endpoint_calls_restart_state(self):
import asyncio import asyncio
from unittest.mock import MagicMock from unittest.mock import MagicMock
+23 -22
View File
@@ -1,32 +1,33 @@
import json from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from zyrarbot.storage.guild_config_repository import GuildConfigRepository import unittest
from zyrarbot.storage.json_file_store import JsonFileStore from unittest.mock import MagicMock
from zyrarbot.storage.adapters.postgres.guild_config import PostgresGuildConfig
class GuildConfigRepositoryTest(unittest.TestCase): class GuildConfigRepositoryTest(unittest.TestCase):
def test_settings_are_isolated_and_legacy_default_moves_to_first_guild(self): def setUp(self):
with tempfile.TemporaryDirectory() as directory: self.database = MagicMock()
path = Path(directory) / "config.json" self.connection = self.database.connect.return_value.__enter__.return_value
path.write_text(json.dumps({"default_plays": 7, "10": 123}), encoding="utf-8") self.repository = PostgresGuildConfig(self.database, fallback_plays=5)
repository = GuildConfigRepository(JsonFileStore(str(path)), fallback_plays=5)
repository.migrate_legacy(10) def test_default_plays_uses_fallback_when_guild_has_no_override(self):
self.assertEqual(repository.get_default_plays(10), 7) self.connection.execute.return_value.fetchone.return_value = (None,)
self.assertEqual(repository.get_notification_channel_id(10), 123)
self.assertEqual(repository.get_default_plays(20), 5)
repository.set_default_plays(20, 9) self.assertEqual(self.repository.get_default_plays(10), 5)
self.assertEqual(repository.get_default_plays(10), 7)
self.assertEqual(repository.get_default_plays(20), 9)
saved = json.loads(path.read_text(encoding="utf-8")) def test_default_plays_returns_guild_override(self):
self.assertNotIn("default_plays", saved) self.connection.execute.return_value.fetchone.return_value = (7,)
self.assertEqual(saved["10"]["default_plays"], 7)
self.assertEqual(saved["20"]["default_plays"], 9) 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__": if __name__ == "__main__":
+35 -38
View File
@@ -1,52 +1,49 @@
import json from __future__ import annotations
import tempfile
import unittest import unittest
from pathlib import Path from unittest.mock import MagicMock
from zyrarbot.music.history_entry import HistoryEntry from zyrarbot.music.history_entry import HistoryEntry
from zyrarbot.storage.history_repository import HistoryRepository from zyrarbot.storage.adapters.postgres.history import PostgresHistory
from zyrarbot.storage.json_file_store import JsonFileStore
class HistoryRepositoryTest(unittest.TestCase): class HistoryRepositoryTest(unittest.TestCase):
def test_history_is_stored_in_separate_files_and_legacy_data_is_migrated(self): def setUp(self):
with tempfile.TemporaryDirectory() as directory: self.database = MagicMock()
legacy_path = Path(directory) / "history.json" self.connection = self.database.connect.return_value.__enter__.return_value
entry = HistoryEntry( self.repository = PostgresHistory(self.database)
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)))
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(
self.assertEqual(repository.list(20), []) self.repository.list(10),
self.assertTrue((Path(directory) / "history" / "10.json").exists()) [
self.assertFalse((Path(directory) / "history" / "20.json").exists()) 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): def test_add_ignores_entries_without_a_title(self):
with tempfile.TemporaryDirectory() as directory: self.repository.add(10, HistoryEntry(id="track-1", title=""))
store = JsonFileStore(str(Path(directory) / "history.json"))
entry = HistoryEntry(
id="track-2",
title="Another track",
url="https://example.test/another-track",
)
HistoryRepository(store).add(42, entry) self.database.connect.assert_not_called()
reloaded = HistoryRepository(store) def test_add_persists_entry_for_guild(self):
self.assertEqual(reloaded.list(42), [entry]) self.connection.execute.return_value.fetchall.return_value = []
persisted = json.loads( entry = HistoryEntry(id="track-1", title="Track", url="https://example.test/track")
(Path(directory) / "history" / "42.json").read_text(encoding="utf-8")
) self.repository.add(10, entry)
self.assertEqual(persisted["entries"][0]["id"], "track-2")
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__": if __name__ == "__main__":
+33 -42
View File
@@ -3,12 +3,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import tempfile
import unittest import unittest
from unittest.mock import MagicMock
from tests.fakes import FakeTrack, build_player from tests.fakes import FakeTrack, build_player
from zyrarbot.music.loop_mode import LoopMode from zyrarbot.music.loop_mode import LoopMode
from zyrarbot.storage.queue_repository import QueueRepository
GUILD = 1 GUILD = 1
@@ -293,28 +292,26 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
async def test_restore_replaces_active_queue_without_old_callback_interference(self): async def test_restore_replaces_active_queue_without_old_callback_interference(self):
player, voice_client = build_player() player, voice_client = build_player()
with tempfile.TemporaryDirectory() as directory: player._queues = MagicMock()
player._queues = QueueRepository(directory) player._queues.load.return_value = [
player._queues.save( {"id": "restored", "title": "Restored", "plays_left": 3}
GUILD, ]
[{"id": "restored", "title": "Restored", "plays_left": 3}],
)
async def from_url(url, loop=None): async def from_url(url, loop=None):
return FakeTrack("restored") return FakeTrack("restored")
player._tracks.from_url = from_url player._tracks.from_url = from_url
await player.enqueue(GUILD, FakeTrack("old")) await player.enqueue(GUILD, FakeTrack("old"))
old_callback = voice_client.after old_callback = voice_client.after
count = await player.restore_queue(GUILD) count = await player.restore_queue(GUILD)
self.assertEqual(count, 1) self.assertEqual(count, 1)
self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"])
self.assertIs(player.state(GUILD).current, player.state(GUILD).queue[0]) self.assertIs(player.state(GUILD).current, player.state(GUILD).queue[0])
old_callback(None) old_callback(None)
await asyncio.sleep(0) await asyncio.sleep(0)
self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"])
async def test_restore_requires_voice_connection(self): async def test_restore_requires_voice_connection(self):
player, _ = build_player() player, _ = build_player()
@@ -324,34 +321,28 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
await player.restore_queue(GUILD) await player.restore_queue(GUILD)
def test_save_queue_does_not_overwrite_with_empty_queue(self): def test_save_queue_does_not_overwrite_with_empty_queue(self):
with tempfile.TemporaryDirectory() as directory: player, _ = build_player()
player, _ = build_player() player._queues = MagicMock()
player._queues = QueueRepository(directory)
saved = [{"id": "saved", "title": "Saved", "plays_left": 2}]
player._queues.save(GUILD, saved)
# Kolejka w stanie odtwarzacza jest pusta # Kolejka w stanie odtwarzacza jest pusta
player.state(GUILD).queue = [] player.state(GUILD).queue = []
player.save_queue(GUILD) player.save_queue(GUILD)
# Zapisana wcześniej kolejka powinna pozostać nienaruszona # Zapisana wcześniej kolejka powinna pozostać nienaruszona
self.assertEqual(player._queues.load(GUILD), saved) player._queues.save.assert_not_called()
def test_save_queue_does_not_save_when_all_plays_consumed(self): def test_save_queue_does_not_save_when_all_plays_consumed(self):
with tempfile.TemporaryDirectory() as directory: player, _ = build_player()
player, _ = build_player() player._queues = MagicMock()
player._queues = QueueRepository(directory)
saved = [{"id": "saved", "title": "Saved", "plays_left": 2}]
player._queues.save(GUILD, saved)
# Wszystkie utwory w kolejce mają plays_left = 0 # Wszystkie utwory w kolejce mają plays_left = 0
track = FakeTrack("consumed") track = FakeTrack("consumed")
track.plays_left = 0 track.plays_left = 0
player.state(GUILD).queue = [track] player.state(GUILD).queue = [track]
player.save_queue(GUILD) player.save_queue(GUILD)
# Zapisana wcześniej kolejka powinna pozostać nienaruszona # Zapisana wcześniej kolejka powinna pozostać nienaruszona
self.assertEqual(player._queues.load(GUILD), saved) player._queues.save.assert_not_called()
class LoopModeTest(unittest.TestCase): class LoopModeTest(unittest.TestCase):
+22 -27
View File
@@ -1,40 +1,35 @@
import tempfile from __future__ import annotations
import unittest
from pathlib import Path
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): class QueueRepositoryTest(unittest.TestCase):
def test_snapshots_are_isolated_per_guild(self): def setUp(self):
with tempfile.TemporaryDirectory() as directory: self.database = MagicMock()
repository = QueueRepository(directory) self.connection = self.database.connect.return_value.__enter__.return_value
first = [{"id": "first", "title": "First", "plays_left": 2}] self.repository = PostgresQueue(self.database)
second = [{"id": "second", "title": "Second", "plays_left": 4}]
repository.save(10, first) def test_load_returns_guild_snapshot(self):
repository.save(20, second) 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(self.repository.load(10), snapshot)
self.assertEqual(repository.load(20), second)
self.assertTrue((Path(directory) / "10.json").exists())
self.assertTrue((Path(directory) / "20.json").exists())
def test_save_empty_tracks_does_nothing(self): def test_save_empty_snapshot_does_not_overwrite_saved_queue(self):
with tempfile.TemporaryDirectory() as directory: self.repository.save(10, [])
repository = QueueRepository(directory)
tracks = [{"id": "first", "title": "First", "plays_left": 2}]
repository.save(10, tracks) self.database.connect.assert_not_called()
self.assertEqual(repository.load(10), tracks)
# Próba zapisania pustej listy nie powinna nadpisać poprzedniej def test_save_upserts_snapshot_for_guild(self):
repository.save(10, []) tracks = [{"id": "track-1", "title": "Track", "plays_left": 2}]
self.assertEqual(repository.load(10), tracks)
# Zapis pustej kolejki dla nowego serwera nie powinien tworzyć pliku self.repository.save(10, tracks)
repository.save(20, [])
self.assertFalse(repository.exists(20)) 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__": if __name__ == "__main__":
+2
View File
@@ -16,6 +16,7 @@ class ShutdownVoiceCleanupTests(unittest.IsolatedAsyncioTestCase):
self.uploads = MagicMock() self.uploads = MagicMock()
self.config = MagicMock() self.config = MagicMock()
self.history = MagicMock() self.history = MagicMock()
self.migration = MagicMock()
self.bot = MusicBot( self.bot = MusicBot(
self.settings, self.settings,
@@ -23,6 +24,7 @@ class ShutdownVoiceCleanupTests(unittest.IsolatedAsyncioTestCase):
self.uploads, self.uploads,
self.config, self.config,
self.history, self.history,
self.migration,
) )
self.player = MagicMock() self.player = MagicMock()
self.bot.player = self.player self.bot.player = self.player
+1 -1
View File
@@ -2,7 +2,7 @@ import tempfile
import unittest import unittest
from pathlib import Path 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 from zyrarbot.ui.bot.cogs.playback_cog import upload_choices
+14 -6
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import json import json
import logging import logging
from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any
import psycopg import psycopg
@@ -71,7 +72,11 @@ class PostgresDatabase:
for key, entries in history.items(): for key, entries in history.items():
if key.isdigit() and isinstance(entries, list): if key.isdigit() and isinstance(entries, list):
_import_history(connection, int(key), entries) _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) _import_history(connection, first_guild_id, entries)
for path in (base / "queues").glob("*.json"): for path in (base / "queues").glob("*.json"):
@@ -109,14 +114,17 @@ def _upsert_guild(
try: try:
value = int(values[field]) value = int(values[field])
except (TypeError, ValueError): 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 continue
connection.execute(f"UPDATE guilds SET {field}=%s WHERE id=%s", (value, guild_id)) connection.execute(f"UPDATE guilds SET {field}=%s WHERE id=%s", (value, guild_id))
def _import_history( def _import_history(connection: psycopg.Connection[Any], guild_id: int, entries: list[Any]) -> None:
connection: psycopg.Connection[Any], guild_id: int, entries: list[Any]
) -> None:
_ensure_guild(connection, guild_id) _ensure_guild(connection, guild_id)
if connection.execute( if connection.execute(
"SELECT 1 FROM history_entries WHERE guild_id=%s LIMIT 1", (guild_id,) "SELECT 1 FROM history_entries WHERE guild_id=%s LIMIT 1", (guild_id,)
@@ -12,7 +12,9 @@ class PostgresGuildConfig:
def _ensure(self, guild_id: int) -> None: def _ensure(self, guild_id: int) -> None:
with self._database.connect() as connection: 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: def get_notification_channel_id(self, guild_id: int) -> int | None:
with self._database.connect() as connection: with self._database.connect() as connection:
@@ -48,10 +50,14 @@ class PostgresGuildConfig:
if guild_id is None: if guild_id is None:
return self._fallback_plays return self._fallback_plays
with self._database.connect() as connection: 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 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: def set_default_plays(self, guild_id: int, value: int) -> None:
self._ensure(guild_id) self._ensure(guild_id)
with self._database.connect() as connection: 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)
)
@@ -25,9 +25,7 @@ class PostgresPersistence:
self.config: GuildConfigRepository = PostgresGuildConfig( self.config: GuildConfigRepository = PostgresGuildConfig(
self.database, fallback_plays=default_plays self.database, fallback_plays=default_plays
) )
self.history: HistoryRepository = PostgresHistory( self.history: HistoryRepository = PostgresHistory(self.database, limit=history_limit)
self.database, limit=history_limit
)
self.queues: QueueRepository = PostgresQueue(self.database) self.queues: QueueRepository = PostgresQueue(self.database)
self._config_dir = config_dir self._config_dir = config_dir