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:
maziggy
2026-10-14 16:03:43 +02:00
parent 930a6661d7
commit fe2bc43720
39 changed files with 366 additions and 58 deletions
+116
View File
@@ -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
+3
View File
@@ -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")