from __future__ import annotations import tempfile import unittest from datetime import datetime, timedelta, timezone from pathlib import Path from alembic import command from alembic.runtime.migration import MigrationContext from sqlalchemy import create_engine, inspect from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_core.db.migrations import alembic_config from govoplan_poll.backend.db.models import PollResponse from govoplan_poll.backend.manifest import get_manifest from govoplan_poll.backend.schemas import ( PollCreateRequest, PollOptionInput, ) from govoplan_poll.backend.service import _existing_response, create_poll _PREVIOUS_POLL_HEAD = "5d6e7f8a9b0c" _POLL_HEAD = "6e7f8a9b0c1d" _INDEX_NAME = "uq_poll_responses_active_respondent" class PollMigrationTests(unittest.TestCase): @staticmethod def _config(url: str): return alembic_config( database_url=url, enabled_modules=("poll",), manifest_factories=(get_manifest,), ) def test_deduplicates_deterministically_and_enforces_partial_index(self) -> None: with tempfile.TemporaryDirectory( prefix="govoplan-poll-migration-" ) as directory: url = f"sqlite:///{Path(directory) / 'poll.db'}" config = self._config(url) command.upgrade(config, _PREVIOUS_POLL_HEAD) engine = create_engine(url) latest = datetime(2026, 7, 22, 10, 0, tzinfo=timezone.utc) historical_deleted_at = latest - timedelta(days=2) historical_updated_at = latest - timedelta(days=1) try: with Session(engine) as session: poll = create_poll( session, tenant_id="tenant-1", user_id="owner-1", payload=PollCreateRequest( title="Migration probe", kind="single_choice", status="open", allow_anonymous=True, options=[ PollOptionInput(key="yes", label="Yes"), PollOptionInput(key="no", label="No"), ], ), ) poll_id = poll.id session.add_all( [ PollResponse( id="identified-oldest", tenant_id="tenant-1", poll_id=poll_id, respondent_id="person-1", answers=[], submitted_at=latest - timedelta(hours=1), ), PollResponse( id="identified-latest-a", tenant_id="tenant-1", poll_id=poll_id, respondent_id="person-1", answers=[], submitted_at=latest, ), PollResponse( id="identified-latest-b", tenant_id="tenant-1", poll_id=poll_id, respondent_id="person-1", answers=[], submitted_at=latest, ), PollResponse( id="anonymous-a", tenant_id="tenant-1", poll_id=poll_id, respondent_id=None, answers=[], submitted_at=latest, ), PollResponse( id="anonymous-b", tenant_id="tenant-1", poll_id=poll_id, respondent_id=None, answers=[], submitted_at=latest, ), PollResponse( id="already-deleted", tenant_id="tenant-1", poll_id=poll_id, respondent_id="person-1", answers=[], submitted_at=latest + timedelta(hours=1), deleted_at=historical_deleted_at, updated_at=historical_updated_at, ), ] ) session.flush() self.assertEqual( _existing_response( session, poll=poll, respondent_id="person-1", ).id, "identified-latest-b", ) session.commit() command.upgrade(config, "heads") with Session(engine) as session: rows = { response.id: response for response in session.query(PollResponse).all() } self.assertIsNone(rows["identified-latest-b"].deleted_at) tombstoned = ( rows["identified-oldest"], rows["identified-latest-a"], ) self.assertTrue( all(response.deleted_at is not None for response in tombstoned) ) self.assertTrue( all( response.deleted_at == response.updated_at for response in tombstoned ) ) self.assertEqual( len({response.deleted_at for response in tombstoned}), 1, ) self.assertIsNone(rows["anonymous-a"].deleted_at) self.assertIsNone(rows["anonymous-b"].deleted_at) self.assertEqual( rows["already-deleted"].deleted_at, historical_deleted_at.replace(tzinfo=None), ) self.assertEqual( rows["already-deleted"].updated_at, historical_updated_at.replace(tzinfo=None), ) session.add( PollResponse( tenant_id="tenant-1", poll_id=poll_id, respondent_id="person-1", answers=[], submitted_at=latest + timedelta(hours=2), ) ) with self.assertRaises(IntegrityError): session.flush() session.rollback() session.add( PollResponse( tenant_id="tenant-1", poll_id=poll_id, respondent_id=None, answers=[], submitted_at=latest + timedelta(hours=2), ) ) session.commit() with engine.connect() as connection: heads = set( MigrationContext.configure(connection).get_current_heads() ) indexes = { item["name"]: item for item in inspect(connection).get_indexes( "poll_responses" ) } self.assertIn(_POLL_HEAD, heads) self.assertIn(_INDEX_NAME, indexes) self.assertTrue(indexes[_INDEX_NAME]["unique"]) finally: engine.dispose() if __name__ == "__main__": unittest.main()