Source code for autowisp.tests.test_bui_models

"""Tests for the browser-interface model base and its timestamp triggers.

Importing this module configures Django against the throwaway user data
directory that ``autowisp.tests`` installs, so the developer's real
``bui_db.sqlite3`` -- which holds their project list -- is never touched.
That redirect happens when the parent package is imported, which is before
this module runs, so nothing here needs to arrange it.
"""

import os
import sys
import time
import unittest
from pathlib import Path

import django

_browser_interface = Path(__file__).resolve().parents[1] / "browser_interface"
if str(_browser_interface) not in sys.path:
    sys.path.insert(0, str(_browser_interface))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_project.settings")
django.setup()

# pylint: disable=wrong-import-position
from django.apps import apps
from django.conf import settings
from django.core.management import call_command
from django.db import connection

from core.models import BuiModelBase
from core.timestamp_triggers import timestamped_models
from home.models import Project

# pylint: enable=wrong-import-position

#: Enough for the millisecond-resolution timestamps the trigger writes to
#: differ between two updates.
_tick = 0.05


[docs] class BuiModelTestCase(unittest.TestCase): """Base migrating the throwaway browser-interface database."""
[docs] @classmethod def setUpClass(cls): assert "autowisp_tests_" in str( settings.DATABASES["default"]["NAME"] ), "refusing to run against a real browser-interface database" call_command("migrate", verbosity=0)
[docs] def make_project(self, name): """Create and return a project row to experiment on.""" # False positive: Django adds the manager at class-creation time. # pylint: disable=no-member return Project.objects.create( name=name, description="", path=f"/tmp/{name}" )
[docs] def stamp(self, project): """Re-read *project*'s ``modified`` from the database.""" # pylint: disable=no-member return Project.objects.get(pk=project.pk).modified
[docs] class TestModelBase(BuiModelTestCase): """Every browser-interface model carries the timestamp columns."""
[docs] def test_project_has_both_columns(self): """``Project`` inherits ``created`` and ``modified``.""" fields = { field.name for field in Project._meta.fields # pylint: disable=protected-access } self.assertIn("created", fields) self.assertIn("modified", fields)
[docs] def test_selection_matches_the_base_class(self): """Exactly the models deriving from the base are selected. Comparing the two sets, rather than checking one known model, is what makes this hold for models added later: the triggers are installed on the strength of a ``modified`` field being present, and this asserts that predicate agrees with deriving from :class:`core.models.BuiModelBase`. A model that gained the column some other way, or one that derived from the base but shadowed the field, would show up here. """ derived = { model for app_config in apps.get_app_configs() for model in app_config.get_models() if issubclass(model, BuiModelBase) } covered = { model for app_config in apps.get_app_configs() for model in timestamped_models(app_config) } self.assertEqual(derived, covered) # Guard against both being empty, which would pass vacuously. self.assertIn(Project, derived)
[docs] def test_contrib_models_are_left_alone(self): """Django's own tables get no trigger; they have no such column.""" self.assertEqual( list(timestamped_models(apps.get_app_config("sessions"))), [] )
[docs] class TestModifiedIsMaintained(BuiModelTestCase): """``modified`` advances however the row is written."""
[docs] def test_every_covered_model_has_its_trigger(self): """``migrate`` leaves a trigger on each selected model's table.""" if connection.vendor != "sqlite": self.skipTest("trigger introspection here is SQLite-specific") with connection.cursor() as cursor: cursor.execute( "SELECT name FROM sqlite_master WHERE type='trigger'" ) names = {row[0] for row in cursor.fetchall()} covered = [ model for app_config in apps.get_app_configs() for model in timestamped_models(app_config) ] self.assertIn(Project, covered) for model in covered: table = model._meta.db_table # pylint: disable=protected-access self.assertIn(f"update_{table}_modified", names)
[docs] def _assert_advances(self, project, write): """Assert *write* moves ``modified`` forward.""" before = self.stamp(project) time.sleep(_tick) write() self.assertGreater( self.stamp(project), before, "modified did not advance", )
[docs] def test_save(self): """The ordinary path, covered by ``auto_now`` alone.""" project = self.make_project("save") self._assert_advances(project, project.save)
[docs] def test_queryset_update(self): """``QuerySet.update`` bypasses ``auto_now`` entirely.""" project = self.make_project("update") self._assert_advances( project, # pylint: disable=no-member lambda: Project.objects.filter(pk=project.pk).update( description="changed" ), )
[docs] def test_bulk_update(self): """``bulk_update`` would otherwise write back a *stale* value. Django reads the in-memory attribute rather than calling ``pre_save``, so without the trigger this does not merely skip the stamp -- it restores the timestamp the object was loaded with. """ project = self.make_project("bulk") # pylint: disable=no-member loaded = Project.objects.get(pk=project.pk) loaded.description = "changed" self._assert_advances( project, lambda: Project.objects.bulk_update([loaded], ["description"]), )
[docs] def test_raw_sql(self): """Statements issued outside the ORM are covered too.""" project = self.make_project("raw") def write(): with connection.cursor() as cursor: cursor.execute( "UPDATE home_project SET description='raw' WHERE id=%s", [project.pk], ) self._assert_advances(project, write)
[docs] class TestMigrationCarriesExistingRows(BuiModelTestCase): """Upgrading an existing database keeps its data and stamps it."""
[docs] def test_existing_row_keeps_created_and_gains_modified(self): """The column is added to a populated table without loss. Also exercises migrating *backwards* across the change, which is what first revealed that SQLite refuses to alter a column while a trigger names it -- hence dropping them in ``pre_migrate``. """ original = "2020-01-02 03:04:05" call_command("migrate", "home", "0001", verbosity=0) try: with connection.cursor() as cursor: cursor.execute( "INSERT INTO home_project" " (name, description, path, created)" " VALUES ('legacy', 'pre-existing', '/tmp/legacy', %s)", [original], ) finally: call_command("migrate", "home", verbosity=0) # pylint: disable=no-member legacy = Project.objects.get(name="legacy") self.assertEqual(legacy.created.strftime("%Y-%m-%d %H:%M:%S"), original) self.assertIsNotNone(legacy.modified)
if __name__ == "__main__": unittest.main()