Merge branch 'release/6.0' into feature/auth
# Conflicts: # Makefile # zyrarbot/storage/adapters/postgres/migrations/__init__.py
This commit is contained in:
16 files changed
+330
-197
No files matched your search
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user