225 lines
8.2 KiB
Python
225 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from alembic.runtime.migration import MigrationContext
|
|
from sqlalchemy import create_engine, func, inspect, select, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.db.migrations import migrate_database
|
|
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
|
|
from govoplan_scheduling.backend.db.models import (
|
|
SchedulingCandidateSlot,
|
|
SchedulingNotification,
|
|
SchedulingParticipant,
|
|
SchedulingRequest,
|
|
)
|
|
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
|
|
|
|
|
|
_SCHEDULING_HEAD = "9c2f4a7d1e6b"
|
|
_ENABLED_MODULES = ("poll", "scheduling")
|
|
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
|
|
|
|
|
|
class SchedulingMigrationTests(unittest.TestCase):
|
|
def _migrate(self, url: str) -> None:
|
|
migrate_database(
|
|
database_url=url,
|
|
enabled_modules=_ENABLED_MODULES,
|
|
manifest_factories=_MANIFEST_FACTORIES,
|
|
)
|
|
|
|
@staticmethod
|
|
def _remove_scheduling_revision(
|
|
connection, *, remove_privacy_column: bool = True
|
|
) -> None:
|
|
connection.execute(
|
|
text("DELETE FROM alembic_version WHERE version_num = :revision"),
|
|
{"revision": _SCHEDULING_HEAD},
|
|
)
|
|
if remove_privacy_column:
|
|
connection.execute(
|
|
text(
|
|
"ALTER TABLE scheduling_requests DROP COLUMN participant_visibility"
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _seed_scheduling_rows(engine) -> None:
|
|
with Session(engine) as session:
|
|
request = SchedulingRequest(
|
|
id="request-1",
|
|
tenant_id="tenant-1",
|
|
title="Migration adoption probe",
|
|
)
|
|
request.slots.append(
|
|
SchedulingCandidateSlot(
|
|
id="slot-1",
|
|
tenant_id="tenant-1",
|
|
label="First slot",
|
|
start_at=datetime(2026, 7, 21, 8, 0),
|
|
end_at=datetime(2026, 7, 21, 9, 0),
|
|
)
|
|
)
|
|
request.participants.append(
|
|
SchedulingParticipant(
|
|
id="participant-1",
|
|
tenant_id="tenant-1",
|
|
display_name="Ada",
|
|
email="ada@example.test",
|
|
)
|
|
)
|
|
session.add(request)
|
|
session.flush()
|
|
session.add(
|
|
SchedulingNotification(
|
|
id="notification-1",
|
|
tenant_id="tenant-1",
|
|
request_id=request.id,
|
|
event_kind="invitation",
|
|
recipient="ada@example.test",
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
def test_adopts_complete_unversioned_v018_schema_and_preserves_rows(self) -> None:
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="govoplan-scheduling-migration-"
|
|
) as directory:
|
|
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
|
self._migrate(url)
|
|
engine = create_engine(url)
|
|
try:
|
|
self._seed_scheduling_rows(engine)
|
|
with engine.begin() as connection:
|
|
self._remove_scheduling_revision(connection)
|
|
|
|
self._migrate(url)
|
|
|
|
with engine.connect() as connection:
|
|
heads = set(
|
|
MigrationContext.configure(connection).get_current_heads()
|
|
)
|
|
columns = {
|
|
item["name"]
|
|
for item in inspect(connection).get_columns(
|
|
"scheduling_requests"
|
|
)
|
|
}
|
|
visibility = connection.execute(
|
|
text(
|
|
"SELECT participant_visibility FROM scheduling_requests "
|
|
"WHERE id = 'request-1'"
|
|
)
|
|
).scalar_one()
|
|
counts = {
|
|
model.__tablename__: connection.execute(
|
|
select(func.count()).select_from(model)
|
|
).scalar_one()
|
|
for model in (
|
|
SchedulingRequest,
|
|
SchedulingCandidateSlot,
|
|
SchedulingParticipant,
|
|
SchedulingNotification,
|
|
)
|
|
}
|
|
|
|
self.assertIn(_SCHEDULING_HEAD, heads)
|
|
self.assertIn("participant_visibility", columns)
|
|
self.assertEqual(visibility, "aggregates_only")
|
|
self.assertEqual(set(counts.values()), {1})
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_adopts_already_present_compatible_privacy_column(self) -> None:
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="govoplan-scheduling-migration-"
|
|
) as directory:
|
|
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
|
self._migrate(url)
|
|
engine = create_engine(url)
|
|
try:
|
|
with engine.begin() as connection:
|
|
self._remove_scheduling_revision(
|
|
connection, remove_privacy_column=False
|
|
)
|
|
|
|
self._migrate(url)
|
|
|
|
with engine.connect() as connection:
|
|
heads = set(
|
|
MigrationContext.configure(connection).get_current_heads()
|
|
)
|
|
matching_columns = [
|
|
item
|
|
for item in inspect(connection).get_columns(
|
|
"scheduling_requests"
|
|
)
|
|
if item["name"] == "participant_visibility"
|
|
]
|
|
|
|
self.assertIn(_SCHEDULING_HEAD, heads)
|
|
self.assertEqual(len(matching_columns), 1)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_rejects_incomplete_existing_baseline(self) -> None:
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="govoplan-scheduling-migration-"
|
|
) as directory:
|
|
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
|
self._migrate(url)
|
|
engine = create_engine(url)
|
|
try:
|
|
with engine.begin() as connection:
|
|
self._remove_scheduling_revision(connection)
|
|
connection.execute(text("DROP INDEX ix_scheduling_requests_status"))
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"scheduling_requests missing indexes: ix_scheduling_requests_status",
|
|
):
|
|
self._migrate(url)
|
|
|
|
with engine.connect() as connection:
|
|
heads = set(
|
|
MigrationContext.configure(connection).get_current_heads()
|
|
)
|
|
self.assertNotIn(_SCHEDULING_HEAD, heads)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_rejects_partial_existing_calendar_extension(self) -> None:
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="govoplan-scheduling-migration-"
|
|
) as directory:
|
|
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
|
self._migrate(url)
|
|
engine = create_engine(url)
|
|
try:
|
|
with engine.begin() as connection:
|
|
self._remove_scheduling_revision(connection)
|
|
connection.execute(text("DROP TABLE scheduling_notifications"))
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"missing table: scheduling_notifications",
|
|
):
|
|
self._migrate(url)
|
|
|
|
with engine.connect() as connection:
|
|
heads = set(
|
|
MigrationContext.configure(connection).get_current_heads()
|
|
)
|
|
self.assertNotIn(_SCHEDULING_HEAD, heads)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|