mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
test: run the backend suite on PostgreSQL too; fix the project import priority
The suite can now run against a throwaway PostgreSQL server (TEST_POSTGRES_URL=postgresql+asyncpg://...@127.0.0.1:port/postgres): each worker gets its own databases, the app takes its PostgreSQL code paths, tests of SQLite-only behaviour are marked sqlite_only. Test data fixed where it relied on SQLite ignoring foreign keys, types and lengths. Found by it: importing a project file without a priority failed on
This commit is contained in:
@@ -36,6 +36,45 @@ os.environ["DEBUG"] = "false"
|
||||
# closed a queue item belonging to an actual running print that way.
|
||||
_TEST_APP_DB_DIR = Path(tempfile.mkdtemp(prefix="bambuddy_test_appdb_"))
|
||||
APP_DATABASE_URL = f"sqlite+aiosqlite:///{_TEST_APP_DB_DIR / 'app.db'}"
|
||||
|
||||
# Opt-in PostgreSQL run. TEST_POSTGRES_URL names a throwaway local server
|
||||
# (postgresql+asyncpg://user:password@127.0.0.1:port/postgres). Each xdist
|
||||
# worker gets two fresh databases there, under a prefix this file owns: one for
|
||||
# the app's own engine, one for the fixtures, kept apart as they are on SQLite.
|
||||
# Nothing else on that server is touched.
|
||||
_PG_DB_PREFIX = "bambuddy_testpg_"
|
||||
TEST_PG_URL: str | None = None
|
||||
if os.environ.get("TEST_POSTGRES_URL"):
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
_pg_server = make_url(os.environ["TEST_POSTGRES_URL"])
|
||||
if _pg_server.drivername != "postgresql+asyncpg" or _pg_server.host not in ("127.0.0.1", "localhost"):
|
||||
raise RuntimeError("TEST_POSTGRES_URL must be a postgresql+asyncpg URL of a local throwaway server")
|
||||
_pg_worker = os.environ.get("PYTEST_XDIST_WORKER", "main")
|
||||
_pg_app_db = f"{_PG_DB_PREFIX}{_pg_worker}_app"
|
||||
_pg_test_db = f"{_PG_DB_PREFIX}{_pg_worker}_test"
|
||||
|
||||
async def _recreate_pg_databases() -> None:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(
|
||||
user=_pg_server.username,
|
||||
password=_pg_server.password,
|
||||
host=_pg_server.host,
|
||||
port=_pg_server.port or 5432,
|
||||
database=_pg_server.database or "postgres",
|
||||
)
|
||||
try:
|
||||
for name in (_pg_app_db, _pg_test_db):
|
||||
await conn.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
||||
await conn.execute(f'CREATE DATABASE "{name}"')
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(_recreate_pg_databases())
|
||||
APP_DATABASE_URL = _pg_server.set(database=_pg_app_db).render_as_string(hide_password=False)
|
||||
TEST_PG_URL = _pg_server.set(database=_pg_test_db).render_as_string(hide_password=False)
|
||||
|
||||
os.environ["DATABASE_URL"] = APP_DATABASE_URL
|
||||
|
||||
|
||||
@@ -56,6 +95,14 @@ def _assert_disposable_database(url, source: str) -> None:
|
||||
instead of reaching the database.
|
||||
"""
|
||||
database = str(getattr(url, "database", "") or "")
|
||||
if TEST_PG_URL is not None:
|
||||
if (
|
||||
str(getattr(url, "drivername", "")) == "postgresql+asyncpg"
|
||||
and getattr(url, "host", None) in ("127.0.0.1", "localhost")
|
||||
and database.startswith(_PG_DB_PREFIX)
|
||||
):
|
||||
return
|
||||
raise RuntimeError(f"Refusing to run tests: {source} resolves to {url!r}, not a {_PG_DB_PREFIX}* database.")
|
||||
if not str(getattr(url, "drivername", "")).startswith("sqlite") or not database.startswith(str(_TEST_APP_DB_DIR)):
|
||||
raise RuntimeError(
|
||||
f"Refusing to run tests: {source} resolves to {url!r}, which is not the "
|
||||
@@ -97,10 +144,39 @@ from backend.app.core.database import Base, engine as _app_engine # noqa: E402
|
||||
# case where that override did not take effect for whatever reason.
|
||||
_assert_disposable_database(_app_engine.url, "backend.app.core.database.engine")
|
||||
|
||||
|
||||
if TEST_PG_URL is not None:
|
||||
# The app's own database gets the schema too, once per worker, as a real
|
||||
# install's has it: code that opens its own session (the backup export,
|
||||
# print completion) reads and writes it. A short-lived engine, so the app
|
||||
# engine's pool holds no connection from this event loop.
|
||||
async def _create_app_schema() -> None:
|
||||
from backend.app.core.database import _create_engine, load_all_models
|
||||
|
||||
load_all_models()
|
||||
schema_engine = _create_engine(APP_DATABASE_URL)
|
||||
try:
|
||||
async with schema_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
finally:
|
||||
await schema_engine.dispose()
|
||||
|
||||
asyncio.run(_create_app_schema())
|
||||
|
||||
# Use in-memory SQLite for tests
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""On PostgreSQL, skip the tests of SQLite-specific behaviour."""
|
||||
if TEST_PG_URL is None:
|
||||
return
|
||||
skip = pytest.mark.skip(reason="SQLite-specific behaviour; the suite runs on PostgreSQL")
|
||||
for item in items:
|
||||
if item.get_closest_marker("sqlite_only"):
|
||||
item.add_marker(skip)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mfa_encryption_isolation(monkeypatch, tmp_path):
|
||||
"""Per-test isolation for MFA encryption state.
|
||||
@@ -286,6 +362,23 @@ async def test_engine():
|
||||
virtual_printer,
|
||||
)
|
||||
|
||||
if TEST_PG_URL is not None:
|
||||
# The same engine setup as the app's (UTC session, aware datetimes
|
||||
# made naive), on this worker's own database, emptied for each test.
|
||||
from sqlalchemy import text
|
||||
|
||||
from backend.app.core.database import _create_engine
|
||||
|
||||
await engine.dispose()
|
||||
engine = _create_engine(TEST_PG_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("DROP SCHEMA public CASCADE"))
|
||||
await conn.execute(text("CREATE SCHEMA public"))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
return
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@@ -589,6 +682,29 @@ def smart_plug_factory(db_session):
|
||||
return _create_plug
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_1(db_session):
|
||||
"""A printer with id 1, for tests whose rows refer to printer 1 directly.
|
||||
|
||||
Without it those rows point at nothing, which only SQLite (foreign keys
|
||||
off) accepts.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
printer = Printer(
|
||||
id=1, name="Printer 1", serial_number="00M09A900000001", ip_address="192.168.1.250", access_code="12345678"
|
||||
)
|
||||
db_session.add(printer)
|
||||
await db_session.flush()
|
||||
if TEST_PG_URL is not None:
|
||||
# An explicit id does not move the sequence on.
|
||||
await db_session.execute(text("SELECT setval(pg_get_serial_sequence('printers', 'id'), 1)"))
|
||||
await db_session.commit()
|
||||
return printer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def printer_factory(db_session):
|
||||
"""Factory to create test printers."""
|
||||
|
||||
@@ -129,6 +129,7 @@ async def test_deactivating_the_owner_disables_the_key(async_client: AsyncClient
|
||||
assert "deactivated" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # a dangling reference needs foreign keys off
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_deleted_owner_does_not_fall_back_to_anonymous(async_client: AsyncClient, db_session):
|
||||
|
||||
@@ -89,6 +89,7 @@ async def test_an_unimportable_backup_is_refused_with_both_versions(async_client
|
||||
assert "Nothing has been changed" in detail
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # switches the app to its own SQLite database file
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_overlay_logo_survives_backup_and_restore(async_client, monkeypatch, tmp_path):
|
||||
|
||||
@@ -103,7 +103,7 @@ async def test_a_deactivated_users_stream_ends(admin, printer, db_session):
|
||||
token = await auth.create_camera_stream_token(username="camadmin")
|
||||
stream = await _open(printer, await _opener(token))
|
||||
|
||||
await db_session.execute(text("UPDATE users SET is_active = 0 WHERE id = :id"), {"id": admin})
|
||||
await db_session.execute(text("UPDATE users SET is_active = false WHERE id = :id"), {"id": admin})
|
||||
await db_session.commit()
|
||||
|
||||
assert await _ended(stream)
|
||||
@@ -128,7 +128,7 @@ async def test_a_disabled_keys_stream_ends(admin, printer, db_session):
|
||||
token = await auth.create_camera_stream_token(api_key_id=key.id)
|
||||
stream = await _open(printer, await _opener(token))
|
||||
|
||||
await db_session.execute(text("UPDATE api_keys SET enabled = 0 WHERE id = :id"), {"id": key.id})
|
||||
await db_session.execute(text("UPDATE api_keys SET enabled = false WHERE id = :id"), {"id": key.id})
|
||||
await db_session.commit()
|
||||
|
||||
assert await _ended(stream)
|
||||
@@ -212,7 +212,7 @@ async def test_a_key_losing_status_access_ends_its_stream(admin, printer, db_ses
|
||||
token = await auth.create_camera_stream_token(api_key_id=key.id)
|
||||
stream = await _open(printer, await _opener(token))
|
||||
|
||||
await db_session.execute(text("UPDATE api_keys SET can_read_status = 0 WHERE id = :id"), {"id": key.id})
|
||||
await db_session.execute(text("UPDATE api_keys SET can_read_status = false WHERE id = :id"), {"id": key.id})
|
||||
await db_session.commit()
|
||||
|
||||
assert await _ended(stream)
|
||||
@@ -294,7 +294,7 @@ async def test_a_definite_no_ends_the_stream_without_a_retry(admin, printer, db_
|
||||
token = await auth.create_camera_stream_token(username="camadmin")
|
||||
stream = await _open(printer, await _opener(token))
|
||||
|
||||
await db_session.execute(text("UPDATE users SET is_active = 0 WHERE id = :id"), {"id": admin})
|
||||
await db_session.execute(text("UPDATE users SET is_active = false WHERE id = :id"), {"id": admin})
|
||||
await db_session.commit()
|
||||
|
||||
assert await _ended(stream)
|
||||
|
||||
@@ -88,6 +88,7 @@ async def _drive_print_start(test_engine, printer, *, download_ok: bool, extra_p
|
||||
current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
|
||||
sdcard=True,
|
||||
sdcard_reported=True,
|
||||
subtask_id="", # a local print; a bare mock attribute would be stored as its repr
|
||||
)
|
||||
|
||||
patches = [
|
||||
@@ -135,6 +136,7 @@ async def _drive_print_start(test_engine, printer, *, download_ok: bool, extra_p
|
||||
relay.on_archive_created = AsyncMock()
|
||||
pm.get_status = MagicMock(return_value=state)
|
||||
pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST1898"))
|
||||
pm.get_client = MagicMock(return_value=None) # else its mock dispatch id is stored
|
||||
|
||||
from backend.app.main import on_print_start
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ async def test_running_twice_changes_nothing(db_session: AsyncSession, model) ->
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # upgrades its own SQLite database
|
||||
async def test_upgrade_from_before_print_log_failure_reason_boots(tmp_path) -> None:
|
||||
"""A database older than #1378 has no print_log_entries.failure_reason.
|
||||
|
||||
@@ -214,6 +215,7 @@ async def test_upgrade_from_before_print_log_failure_reason_boots(tmp_path) -> N
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # upgrades its own SQLite database
|
||||
async def test_archive_missing_from_the_fts_index_is_converted(tmp_path) -> None:
|
||||
"""Archives created before archive_fts existed were never indexed.
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ class TestCrud:
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.sqlite_only # an over-length value only SQLite stores
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_row_that_predates_the_bound_is_still_readable(
|
||||
|
||||
@@ -133,6 +133,7 @@ class TestCrud:
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.sqlite_only # an over-length value only SQLite stores
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_row_that_predates_the_bound_is_still_readable(
|
||||
|
||||
@@ -5139,6 +5139,7 @@ class TestListOidcLinksDefensiveProviderNull:
|
||||
row is removed without the ORM cascade running.
|
||||
"""
|
||||
|
||||
@pytest.mark.sqlite_only # a dangling reference needs foreign keys off
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_oidc_links_returns_deleted_marker_for_orphan_provider(
|
||||
|
||||
@@ -901,6 +901,38 @@ class TestPolishFollowUp:
|
||||
assert survivors["total"] == 2
|
||||
assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
|
||||
|
||||
@staticmethod
|
||||
async def _dispatched_run(db_session, pipeline_factory, printer_factory, library_file_factory):
|
||||
"""A run in 'dispatching' whose one job is queued as a real queue entry."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
printer = await printer_factory()
|
||||
pipe = await pipeline_factory(target_printer_id=printer.id)
|
||||
src = await library_file_factory()
|
||||
item = PrintQueueItem(printer_id=printer.id, status="pending", library_file_id=src.id)
|
||||
run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="dispatching")
|
||||
db_session.add_all([item, run])
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
PipelineJob(
|
||||
pipeline_run_id=run.id,
|
||||
copy_index=0,
|
||||
queue_entry_id=item.id,
|
||||
assigned_printer_id=printer.id,
|
||||
status="queued",
|
||||
dispatched_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return run.id, item.id, src.id
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="A job keeps status 'queued' after dispatch; once its queue entry is deleted, a pending copy cannot be told from a printed one. Needs the job's final status stored when its entry ends.",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deleted_queue_entry_rolls_up_as_cancelled(
|
||||
@@ -914,42 +946,87 @@ class TestPolishFollowUp:
|
||||
"""When the queue entry that a PipelineJob is linked to gets deleted
|
||||
from the print-queue page, the job's live status should roll up to
|
||||
``cancelled`` so the run doesn't sit forever showing ``queued`` /
|
||||
``dispatching``."""
|
||||
``dispatching``. The delete leaves the job without its queue entry."""
|
||||
run_id, item_id, _ = await self._dispatched_run(
|
||||
db_session, pipeline_factory, printer_factory, library_file_factory
|
||||
)
|
||||
|
||||
assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
|
||||
resp = await async_client.get(f"/api/v1/pipeline-runs/{run_id}")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["jobs"][0]["status"] == "cancelled"
|
||||
assert body["status"] == "cancelled"
|
||||
assert body["copies_cancelled"] == 1 and body["copies_in_progress"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_job_never_dispatched_is_not_called_cancelled(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
pipeline_factory,
|
||||
printer_factory,
|
||||
library_file_factory,
|
||||
db_session,
|
||||
):
|
||||
"""No queue entry yet because it was never dispatched: still waiting."""
|
||||
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
|
||||
|
||||
printer = await printer_factory()
|
||||
pipe = await pipeline_factory(target_printer_id=printer.id)
|
||||
src = await library_file_factory()
|
||||
# Simulate the state PR C leaves a successful dispatch in: run is
|
||||
# 'dispatching' and the job has a queue_entry_id pointing at a
|
||||
# PrintQueueItem that no longer exists.
|
||||
run = PipelineRun(
|
||||
pipeline_id=pipe["id"],
|
||||
source_library_file_id=src.id,
|
||||
copies=1,
|
||||
status="dispatching",
|
||||
)
|
||||
run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="slicing")
|
||||
db_session.add(run)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
PipelineJob(
|
||||
pipeline_run_id=run.id,
|
||||
copy_index=0,
|
||||
queue_entry_id=999999, # Doesn't exist — simulates manual delete from queue.
|
||||
assigned_printer_id=printer.id,
|
||||
status="queued",
|
||||
)
|
||||
)
|
||||
db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, status="pending"))
|
||||
await db_session.commit()
|
||||
await db_session.refresh(run)
|
||||
|
||||
resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# Job rolled up to cancelled because the queue entry is gone.
|
||||
assert body["jobs"][0]["status"] == "cancelled"
|
||||
# Run also rolls up — all jobs cancelled → run reads as cancelled.
|
||||
assert body["status"] == "cancelled"
|
||||
body = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}")).json()
|
||||
|
||||
assert body["jobs"][0]["status"] == "pending"
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="A job keeps status 'queued' after dispatch; once its queue entry is deleted, a pending copy cannot be told from a printed one. Needs the job's final status stored when its entry ends.",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_retry_counts_a_deleted_queue_entry_as_cancelled(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
pipeline_factory,
|
||||
printer_factory,
|
||||
library_file_factory,
|
||||
db_session,
|
||||
):
|
||||
"""What the run view shows as cancelled, retry starts again."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class _FakeSliceJob:
|
||||
id: int = 6667
|
||||
|
||||
run_id, item_id, _ = await self._dispatched_run(
|
||||
db_session, pipeline_factory, printer_factory, library_file_factory
|
||||
)
|
||||
assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
|
||||
|
||||
live_status = {"connected": True, "raw_data": {"ams": []}}
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.pipeline_runs._load_printer_status",
|
||||
new=AsyncMock(return_value=live_status),
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.slice_dispatch.slice_dispatch.enqueue",
|
||||
new=AsyncMock(return_value=_FakeSliceJob()),
|
||||
),
|
||||
):
|
||||
resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
|
||||
|
||||
assert resp.status_code == 202, resp.text
|
||||
assert resp.json()["copies"] == 1
|
||||
|
||||
|
||||
class TestCancelTerminal:
|
||||
|
||||
@@ -44,8 +44,12 @@ class TestPlateClearNotificationDispatch:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_skips_a_provider_scoped_to_a_different_printer(self, notification_provider_factory, db_session):
|
||||
await notification_provider_factory(name="Other Printer", on_plate_clear_required=True, printer_id=99)
|
||||
async def test_skips_a_provider_scoped_to_a_different_printer(
|
||||
self, notification_provider_factory, printer_factory, db_session
|
||||
):
|
||||
await printer_factory(name="Workshop")
|
||||
other = await printer_factory(name="Other")
|
||||
await notification_provider_factory(name="Other Printer", on_plate_clear_required=True, printer_id=other.id)
|
||||
|
||||
send = AsyncMock()
|
||||
with patch.object(notification_service, "_send_to_providers", send):
|
||||
|
||||
@@ -355,6 +355,7 @@ async def test_another_printers_archive_adopted_for_a_deleted_printer_stays_that
|
||||
assert archive.status == "completed"
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # deletes the printer with its archives still attached
|
||||
@pytest.mark.parametrize("branch", ["fallback", "downloaded", "queued"])
|
||||
async def test_a_printer_deleted_during_the_last_steps_is_forgotten_again(
|
||||
test_engine, db_session, printer_factory, branch
|
||||
|
||||
@@ -85,6 +85,7 @@ async def test_the_print_being_tracked_goes_with_the_printer(async_client: Async
|
||||
assert await _session_rows(db_session) == ([], [])
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # stages stale ids with a raw delete, which foreign keys refuse or null out
|
||||
async def test_the_startup_repair_drops_print_tracking_of_deleted_printers(db_session):
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ async def test_obico_detection_drops_the_deleted_printer(async_client: AsyncClie
|
||||
assert (await db_session.execute(statement.execution_options(populate_existing=True))).scalar() == f"[{a},{c.id}]"
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # stages stale ids with a raw delete, which foreign keys refuse or null out
|
||||
async def test_the_startup_repair_drops_deleted_printers_from_obico_and_virtual_printers(db_session):
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
@@ -346,6 +347,7 @@ async def test_obico_ids_already_stored_dont_block_a_save(async_client: AsyncCli
|
||||
assert response.status_code == 400, response.text
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # stages stale ids with a raw delete, which foreign keys refuse or null out
|
||||
async def test_the_startup_trim_of_printer_lists_runs_on_postgresql_too(db_session):
|
||||
"""PostgreSQL holds stale ids too: kept by its own earlier deletes, or copied
|
||||
from a SQLite backup, after which its sequence hands out the deleted id again."""
|
||||
|
||||
@@ -313,6 +313,7 @@ class TestExistingData:
|
||||
assert styled.status_code == 201
|
||||
assert third.status_code == 409
|
||||
|
||||
@pytest.mark.sqlite_only # an over-length value only SQLite stores
|
||||
async def test_an_overlong_stored_name_can_be_renamed_and_deleted(self, async_client, printer_factory, db_session):
|
||||
"""SQLite never enforced VARCHAR(100)."""
|
||||
long_name = "L" * 150
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""An imported project gets a priority the project editor accepts.
|
||||
|
||||
A file without one used to get the number 0: refused by PostgreSQL (a text
|
||||
column) and stored as a priority the editor itself refuses on SQLite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _import_file(client: AsyncClient, data: dict):
|
||||
files = {"file": ("project.json", io.BytesIO(json.dumps(data).encode()), "application/json")}
|
||||
return await client.post("/api/v1/projects/import/file", files=files)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("given", "expected"),
|
||||
[(None, "normal"), ("high", "high"), ("urgent", "urgent"), (0, "normal"), ("whenever", "normal")],
|
||||
)
|
||||
async def test_a_file_import_gets_a_valid_priority(async_client: AsyncClient, given, expected):
|
||||
data = {"name": "Imported"} if given is None else {"name": "Imported", "priority": given}
|
||||
|
||||
response = await _import_file(async_client, data)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["priority"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("given", "expected"), [("low", "low"), ("whenever", "normal")])
|
||||
async def test_a_json_import_gets_a_valid_priority(async_client: AsyncClient, given, expected):
|
||||
response = await async_client.post("/api/v1/projects/import", json={"name": "Imported", "priority": given})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["priority"] == expected
|
||||
@@ -132,9 +132,14 @@ class TestStartCreditsTheClicker:
|
||||
|
||||
# Pre-set a different owner on the queue item using a fresh session
|
||||
# (the test's `db_session` is detached from the route's session pool).
|
||||
prior_owner_id = admin_user["id"] + 9999
|
||||
from backend.app.models.user import User
|
||||
|
||||
maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with maker() as fresh:
|
||||
prior_owner = User(username="prior-owner", password_hash="x")
|
||||
fresh.add(prior_owner)
|
||||
await fresh.flush()
|
||||
prior_owner_id = prior_owner.id
|
||||
item = (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == queue_item.id))).scalar_one()
|
||||
item.created_by_id = prior_owner_id
|
||||
await fresh.commit()
|
||||
@@ -219,6 +224,7 @@ class TestSchedulerPropagatesOwnerToPrinterManager:
|
||||
await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
|
||||
assert captured == []
|
||||
|
||||
@pytest.mark.sqlite_only # a dangling reference needs foreign keys off
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_noop_when_user_row_missing(self, db_session, queue_item, monkeypatch):
|
||||
|
||||
@@ -100,6 +100,7 @@ def _socket(user_id=None, key_id=None):
|
||||
return socket
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # switches the app to its own SQLite database file
|
||||
@pytest.mark.parametrize("auth_on", [True, False])
|
||||
async def test_a_backup_restore_closes_the_signed_in_sockets(async_client: AsyncClient, monkeypatch, tmp_path, auth_on):
|
||||
"""With auth on in the restored data, sessions end, and what the restored
|
||||
@@ -151,6 +152,7 @@ async def test_a_backup_restore_closes_the_signed_in_sockets(async_client: Async
|
||||
ws_manager.active_connections.remove(socket)
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # switches the app to its own SQLite database file
|
||||
async def test_a_restore_that_turns_authentication_on_closes_the_anonymous_sockets(
|
||||
async_client: AsyncClient, monkeypatch, tmp_path
|
||||
):
|
||||
@@ -484,6 +486,7 @@ async def test_turning_authentication_on_drops_the_tokens_minted_while_it_was_of
|
||||
assert sorted(left.scalars().all()) == ["logged-out"]
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # switches the app to its own SQLite database file
|
||||
async def test_a_restore_reads_the_restored_auth_setting_not_the_cached_one(
|
||||
async_client: AsyncClient, monkeypatch, tmp_path
|
||||
):
|
||||
|
||||
@@ -166,6 +166,7 @@ async def test_the_next_user_with_the_id_is_not_asked_for_an_email_code(async_cl
|
||||
assert await _get_email_2fa_enabled(db_session, bob) is False
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # the startup repair of SQLite leftovers
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_startup_repair_drops_email_2fa_of_users_already_deleted(db_session):
|
||||
|
||||
@@ -706,6 +706,7 @@ class TestVirtualPrinterSerialSurface:
|
||||
# shape — model-specific prefix + 8-char hex suffix from `vp.serial_suffix`.
|
||||
assert len(create_resp.json()["serial"]) >= 8
|
||||
|
||||
@pytest.mark.sqlite_only # a dangling reference needs foreign keys off
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_proxy_vp_falls_back_to_self_generated_when_target_missing(
|
||||
|
||||
@@ -795,9 +795,7 @@ class TestFilamentDeficitBackupAware:
|
||||
)
|
||||
short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#000000", slicer_filament="GFA00")
|
||||
# Same colour but expressed with explicit alpha.
|
||||
peer = await _spool(
|
||||
db_session, label_weight=1000, weight_used=500.0, color="#000000FF", slicer_filament="GFA00"
|
||||
)
|
||||
peer = await _spool(db_session, label_weight=1000, weight_used=500.0, color="000000FF", slicer_filament="GFA00")
|
||||
await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
|
||||
await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
|
||||
item = await _queue_item(
|
||||
@@ -838,7 +836,7 @@ class TestBuildSlotMaterials:
|
||||
from backend.app.services.filament_deficit import build_slot_materials
|
||||
|
||||
printer = await printer_factory(model="H2D")
|
||||
a3 = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#616777FF", slicer_filament="PFUS6488")
|
||||
a3 = await _spool(db_session, label_weight=1000, weight_used=0.0, color="616777FF", slicer_filament="PFUS6488")
|
||||
a4 = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#616777", slicer_filament="PFUS6488")
|
||||
await _assign(db_session, printer_id=printer.id, spool_id=a3.id, ams_id=0, tray_id=2)
|
||||
await _assign(db_session, printer_id=printer.id, spool_id=a4.id, ams_id=0, tray_id=3)
|
||||
|
||||
@@ -161,6 +161,7 @@ def test_the_upgrade_gives_what_a_fresh_install_gets():
|
||||
assert _db._PASSWORD_RESET_TEMPLATE_BODY[1] == DEFAULT["body_template"]
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # upgrades its own SQLite database
|
||||
async def test_start_up_runs_the_upgrade(tmp_path):
|
||||
from sqlalchemy.ext.asyncio import AsyncSession as Session
|
||||
|
||||
|
||||
@@ -1624,8 +1624,12 @@ async def test_auto_assign_kprofile_takes_priority_over_live_cali_idx(db_session
|
||||
|
||||
# Use a fully-mocked spool so SA relationship instrumentation is bypassed.
|
||||
# auto_assign_spool only reads attributes — it never persists via the spool.
|
||||
# The assignment it writes still points at a real spool row.
|
||||
row = Spool(material="PLA")
|
||||
db_session.add(row)
|
||||
await db_session.flush()
|
||||
spool = MagicMock(spec=Spool)
|
||||
spool.id = 999
|
||||
spool.id = row.id
|
||||
spool.material = "PLA"
|
||||
spool.slicer_filament = None
|
||||
spool.k_profiles = [kp_mock]
|
||||
|
||||
@@ -150,6 +150,7 @@ def test_the_upgrade_gives_what_a_fresh_install_gets():
|
||||
assert _db._WELCOME_TEMPLATE_BODY[1] == DEFAULT["body_template"]
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # upgrades its own SQLite database
|
||||
async def test_start_up_runs_the_upgrade(tmp_path):
|
||||
from backend.app.core.database import Base, run_migrations
|
||||
from backend.app.main import app # noqa: F401 registers every model, as init_db does
|
||||
|
||||
@@ -25,6 +25,9 @@ from backend.app.services.usage_tracker import (
|
||||
_threemf_search_stem,
|
||||
)
|
||||
|
||||
# Their rows refer to printer 1.
|
||||
pytestmark = pytest.mark.usefixtures("printer_1")
|
||||
|
||||
|
||||
class TestSearchStem:
|
||||
def test_a_real_filename_still_wins(self):
|
||||
|
||||
@@ -17,6 +17,7 @@ import pytest
|
||||
from backend.app.main import _completion_belongs_to_queue_item, _subtask_name_from_filename
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
|
||||
class TestSubtaskNameFromFilename:
|
||||
@@ -47,10 +48,13 @@ class TestSubtaskNameFromFilename:
|
||||
|
||||
async def _seed(db, *, archive_filename: str | None) -> PrintQueueItem:
|
||||
"""A printing queue item, optionally linked to an archive."""
|
||||
printer = Printer(name="P", serial_number="00M09A000000001", ip_address="192.168.1.100", access_code="12345678")
|
||||
db.add(printer)
|
||||
await db.flush()
|
||||
archive_id = None
|
||||
if archive_filename is not None:
|
||||
archive = PrintArchive(
|
||||
printer_id=1,
|
||||
printer_id=printer.id,
|
||||
filename=archive_filename,
|
||||
file_path=f"archive/1/{archive_filename}",
|
||||
file_size=1,
|
||||
@@ -60,7 +64,7 @@ async def _seed(db, *, archive_filename: str | None) -> PrintQueueItem:
|
||||
await db.flush()
|
||||
archive_id = archive.id
|
||||
|
||||
item = PrintQueueItem(printer_id=1, status="printing", archive_id=archive_id)
|
||||
item = PrintQueueItem(printer_id=printer.id, status="printing", archive_id=archive_id)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
return item
|
||||
@@ -107,6 +111,7 @@ class TestCompletionBelongsToQueueItem:
|
||||
|
||||
assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
|
||||
|
||||
@pytest.mark.sqlite_only # a dangling reference needs foreign keys off
|
||||
async def test_lets_a_dangling_archive_reference_through(self, db_session):
|
||||
item = await _seed(db_session, archive_filename=None)
|
||||
item.archive_id = 999999
|
||||
@@ -114,6 +119,7 @@ class TestCompletionBelongsToQueueItem:
|
||||
assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # on PostgreSQL the guard checks the throwaway server's databases instead
|
||||
class TestDisposableDatabaseGuard:
|
||||
"""The suite must never be able to open a session against a real database.
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ class TestQueueAgeUsesUtc:
|
||||
"""The support bundle's ``oldest_pending_age_seconds`` (#2855)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("printer_1")
|
||||
async def test_age_of_a_fresh_item_is_near_zero_on_a_non_utc_host(self, db_session, istanbul_tz):
|
||||
"""The old code subtracted a naive *local* now() from a naive UTC column,
|
||||
so on UTC+3 a just-queued item reported as three hours old — and west of
|
||||
|
||||
@@ -40,6 +40,9 @@ from backend.app.services.usage_tracker import (
|
||||
_resolve_3mf_fallback,
|
||||
)
|
||||
|
||||
# Their rows refer to printer 1.
|
||||
pytestmark = pytest.mark.usefixtures("printer_1")
|
||||
|
||||
|
||||
def _write_3mf(path: Path, *, plate: int, filaments: int) -> Path:
|
||||
"""A single-plate 3MF declaring *filaments* filaments on plate *plate*."""
|
||||
|
||||
@@ -50,6 +50,7 @@ async def test_signs_every_user_out_and_drops_short_lived_auth_records(test_engi
|
||||
assert (await db_session.execute(select(AuthRateLimitEvent))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # switches the app to its own SQLite database file
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_ends_by_invalidating_all_sessions(async_client, monkeypatch, tmp_path):
|
||||
from backend.app.api.routes.settings import create_backup_zip
|
||||
|
||||
@@ -9,6 +9,7 @@ strips it on import; ``_migrate_drop_library_print_name`` clears it from rows
|
||||
imported before the fix.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.api.routes.library import _without_print_name
|
||||
@@ -47,6 +48,7 @@ def test_print_name_only_collapses_to_empty_dict():
|
||||
# --- _migrate_drop_library_print_name --------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # pins the SQLite branch of the migration
|
||||
async def test_migration_strips_print_name_from_existing_rows(db_session, monkeypatch):
|
||||
"""Rows imported before the fix get print_name cleared; siblings and rows
|
||||
that never had it are untouched. Idempotent on a second run.
|
||||
|
||||
@@ -132,6 +132,7 @@ class TestSqliteFallback:
|
||||
assert _is_already_applied(exc, sql) is expected
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # runs migrations against a real SQLite database
|
||||
@pytest.mark.asyncio
|
||||
class TestSafeExecuteAgainstRealSqlite:
|
||||
async def test_a_duplicate_add_column_is_swallowed(self, db_session):
|
||||
|
||||
@@ -27,6 +27,7 @@ import importlib
|
||||
import pkgutil
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from backend.app.core.database import Base
|
||||
@@ -69,6 +70,7 @@ def test_the_tables_that_used_to_cycle_sort_parents_first():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.sqlite_only # SQLite table rebuild
|
||||
def test_the_altered_constraint_still_exists_on_sqlite():
|
||||
"""use_alter asks for ALTER TABLE ADD CONSTRAINT, and SQLite has no such
|
||||
statement. It inlines the key into CREATE TABLE instead -- but if that ever
|
||||
|
||||
@@ -12,6 +12,9 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Their rows refer to printer 1.
|
||||
pytestmark = pytest.mark.usefixtures("printer_1")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dirs(monkeypatch, tmp_path):
|
||||
|
||||
@@ -15,7 +15,8 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from backend.app.core.database import AUTOINCREMENT_TABLES, Base, _migrate_sqlite_autoincrement, load_all_models
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
# SQLite behaviour: PostgreSQL sequences never hand an id out twice.
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.sqlite_only]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -17,6 +17,9 @@ from backend.app.models.print_log import PrintLogEntry
|
||||
from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
# The SQLite stand-in for foreign key rules PostgreSQL applies itself.
|
||||
pytestmark = pytest.mark.sqlite_only
|
||||
|
||||
|
||||
async def _archive_with_dependents(db):
|
||||
printer = Printer(name="P", serial_number="S1", ip_address="192.0.2.1", access_code="x")
|
||||
|
||||
Reference in New Issue
Block a user