diff --git a/.github/actions/setup-python-deps/action.yml b/.github/actions/setup-python-deps/action.yml new file mode 100644 index 0000000..7a973f8 --- /dev/null +++ b/.github/actions/setup-python-deps/action.yml @@ -0,0 +1,30 @@ +name: 'Setup Python and Dependencies' +description: 'Common setup steps for Python and pip dependencies' + +inputs: + python-version: + description: 'Python version to setup' + required: true + cache-key-prefix: + description: 'Prefix for pip cache key' + required: true + +runs: + using: 'composite' + steps: + - name: Set up Python ${{ inputs.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ inputs.cache-key-prefix }}-${{ inputs.python-version }}-${{ hashFiles('**/pyproject.toml') }} + + - name: Install Python dependencies + shell: bash + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 347622f..5cc0c5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,67 +3,125 @@ name: Test on: [push, pull_request] jobs: - build: + test-sqlite: + name: SQLite • Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: fail-fast: false - max-parallel: 5 matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Setup Python and dependencies + uses: ./.github/actions/setup-python-deps + with: + python-version: ${{ matrix.python-version }} + cache-key-prefix: sqlite3 + + - name: Run tests + env: + TEST_DB_BACKEND: sqlite3 + run: tox -v + + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + name: SQLite • Python ${{ matrix.python-version }} + + test-postgres: + name: PostgreSQL • Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] services: postgres: - image: postgres:14 + image: postgres:15 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres + POSTGRES_DB: auditlog ports: - - 5432/tcp + - 5432/tcp options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s - --health-retries 5 - + --health-retries 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} + - name: Setup Python and dependencies + uses: ./.github/actions/setup-python-deps + with: + python-version: ${{ matrix.python-version }} + cache-key-prefix: postgresql - - name: Get pip cache dir - id: pip-cache - run: | - echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + - name: Run tests + env: + TEST_DB_BACKEND: postgresql + TEST_DB_HOST: localhost + TEST_DB_USER: postgres + TEST_DB_PASS: postgres + TEST_DB_NAME: auditlog + TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }} - - name: Cache - uses: actions/cache@v4 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: - -${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }} - restore-keys: | - -${{ matrix.python-version }}-v1- + run: tox -v - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-gh-actions + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + name: PostgreSQL • Python ${{ matrix.python-version }} - - name: Tox tests - run: | - tox -v - env: - TEST_DB_HOST: localhost - TEST_DB_USER: postgres - TEST_DB_PASS: postgres - TEST_DB_NAME: postgres - TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }} + test-mysql: + name: MySQL • Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: auditlog + MYSQL_USER: mysql + MYSQL_PASSWORD: mysql + MYSQL_ROOT_PASSWORD: mysql + ports: + - 3306/tcp + options: >- + --health-cmd="sh -c 'export MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\"; mysqladmin ping -h 127.0.0.1 --protocol=TCP -uroot --silent || exit 1'" + --health-interval=10s + --health-timeout=5s + --health-retries=20 + steps: + - uses: actions/checkout@v4 - - name: Upload coverage - uses: codecov/codecov-action@v5 - with: - name: Python ${{ matrix.python-version }} + - name: Install MySQL client libraries + run: | + sudo apt-get update + sudo apt-get install -y libmysqlclient-dev pkg-config mysql-client + + - name: Setup Python and dependencies + uses: ./.github/actions/setup-python-deps + with: + python-version: ${{ matrix.python-version }} + cache-key-prefix: mysql + + - name: Run tests + env: + TEST_DB_BACKEND: mysql + TEST_DB_HOST: 127.0.0.1 + TEST_DB_USER: root + TEST_DB_PASS: mysql + TEST_DB_NAME: auditlog + TEST_DB_PORT: ${{ job.services.mysql.ports[3306] }} + run: tox -v + + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + name: MySQL • Python ${{ matrix.python-version }} diff --git a/auditlog/management/commands/auditlogflush.py b/auditlog/management/commands/auditlogflush.py index 7a42aa6..8231e27 100644 --- a/auditlog/management/commands/auditlogflush.py +++ b/auditlog/management/commands/auditlogflush.py @@ -83,7 +83,7 @@ class Command(BaseCommand): class TruncateQuery: - SUPPORTED_VENDORS = ("postgresql", "mysql", "sqlite", "oracle", "microsoft") + SUPPORTED_VENDORS = ("postgresql", "mysql", "oracle", "microsoft") @classmethod def support_truncate_statement(cls, database_vendor) -> bool: diff --git a/auditlog_tests/docker-compose.yml b/auditlog_tests/docker-compose.yml new file mode 100644 index 0000000..07099a1 --- /dev/null +++ b/auditlog_tests/docker-compose.yml @@ -0,0 +1,47 @@ +services: + postgres: + container_name: auditlog_postgres + image: postgres:15 + restart: "no" + environment: + POSTGRES_DB: auditlog + POSTGRES_USER: ${TEST_DB_USER} + POSTGRES_PASSWORD: ${TEST_DB_PASS} + ports: + - "${TEST_DB_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: pg_isready -U ${TEST_DB_USER} -d auditlog + interval: 5s + timeout: 3s + retries: 5 + + mysql: + container_name: auditlog_mysql + platform: linux/x86_64 + image: mysql:8.0 + restart: "no" + environment: + MYSQL_DATABASE: auditlog + MYSQL_USER: ${TEST_DB_USER} + MYSQL_PASSWORD: ${TEST_DB_PASS} + MYSQL_ROOT_PASSWORD: ${TEST_DB_PASS} + ports: + - "${TEST_DB_PORT:-3306}:3306" + expose: + - '${TEST_DB_PORT:-3306}' + volumes: + - mysql-data:/var/lib/mysql + - ./docker/db/init-mysql.sh:/docker-entrypoint-initdb.d/init.sh + healthcheck: + test: mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS} + interval: 5s + timeout: 3s + retries: 3 + +volumes: + postgres-data: + driver: local + mysql-data: + driver: local diff --git a/auditlog_tests/docker/db/init-mysql.sh b/auditlog_tests/docker/db/init-mysql.sh new file mode 100755 index 0000000..c0f61bb --- /dev/null +++ b/auditlog_tests/docker/db/init-mysql.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL + GRANT ALL PRIVILEGES ON test_auditlog.* to '$MYSQL_USER'; +EOSQL diff --git a/auditlog_tests/test_app/models.py b/auditlog_tests/test_app/models.py index efd6552..996407e 100644 --- a/auditlog_tests/test_app/models.py +++ b/auditlog_tests/test_app/models.py @@ -1,6 +1,6 @@ import uuid -from django.contrib.postgres.fields import ArrayField +from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder from django.db import models @@ -311,26 +311,36 @@ class CharfieldTextfieldModel(models.Model): history = AuditlogHistoryField(delete_related=True) -class PostgresArrayFieldModel(models.Model): - """ - Test auditlog with Postgres's ArrayField - """ +# Only define PostgreSQL-specific models when ArrayField is available +if settings.TEST_DB_BACKEND == "postgresql": + from django.contrib.postgres.fields import ArrayField - RED = "r" - YELLOW = "y" - GREEN = "g" + class PostgresArrayFieldModel(models.Model): + """ + Test auditlog with Postgres's ArrayField + """ - STATUS_CHOICES = ( - (RED, "Red"), - (YELLOW, "Yellow"), - (GREEN, "Green"), - ) + RED = "r" + YELLOW = "y" + GREEN = "g" - arrayfield = ArrayField( - models.CharField(max_length=1, choices=STATUS_CHOICES), size=3 - ) + STATUS_CHOICES = ( + (RED, "Red"), + (YELLOW, "Yellow"), + (GREEN, "Green"), + ) - history = AuditlogHistoryField(delete_related=True) + arrayfield = ArrayField( + models.CharField(max_length=1, choices=STATUS_CHOICES), size=3 + ) + + history = AuditlogHistoryField(delete_related=True) + +else: + + class PostgresArrayFieldModel(models.Model): + class Meta: + managed = False class NoDeleteHistoryModel(models.Model): @@ -448,7 +458,8 @@ auditlog.register(AdditionalDataIncludedModel) auditlog.register(DateTimeFieldModel) auditlog.register(ChoicesFieldModel) auditlog.register(CharfieldTextfieldModel) -auditlog.register(PostgresArrayFieldModel) +if settings.TEST_DB_BACKEND == "postgresql": + auditlog.register(PostgresArrayFieldModel) auditlog.register(NoDeleteHistoryModel) auditlog.register(JSONModel) auditlog.register(NullableJSONModel) diff --git a/auditlog_tests/test_commands.py b/auditlog_tests/test_commands.py index 5c446aa..da3838d 100644 --- a/auditlog_tests/test_commands.py +++ b/auditlog_tests/test_commands.py @@ -6,9 +6,13 @@ from unittest import mock import freezegun from django.core.management import call_command +from django.db import connection from django.test import TestCase, TransactionTestCase +from django.test.utils import skipIf from test_app.models import SimpleModel +from auditlog.management.commands.auditlogflush import TruncateQuery + class AuditlogFlushTest(TestCase): def setUp(self): @@ -139,6 +143,10 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase): ) self.assertEqual(err, "", msg="No stderr") + @skipIf( + not TruncateQuery.support_truncate_statement(connection.vendor), + "Database does not support TRUNCATE", + ) def test_flush_with_truncate_and_yes(self): obj = self.make_object() self.assertEqual(obj.history.count(), 1, msg="There is one log entry.") @@ -152,6 +160,10 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase): ) self.assertEqual(err, "", msg="No stderr") + @skipIf( + not TruncateQuery.support_truncate_statement(connection.vendor), + "Database does not support TRUNCATE", + ) def test_flush_with_truncate_with_input_yes(self): obj = self.make_object() self.assertEqual(obj.history.count(), 1, msg="There is one log entry.") diff --git a/auditlog_tests/test_postgresql.py b/auditlog_tests/test_postgresql.py new file mode 100644 index 0000000..6afee24 --- /dev/null +++ b/auditlog_tests/test_postgresql.py @@ -0,0 +1,51 @@ +""" +PostgreSQL-specific tests for django-auditlog. +""" + +from unittest import skipIf + +from django.conf import settings +from django.test import TestCase +from test_app.models import PostgresArrayFieldModel + + +@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test") +class PostgresArrayFieldModelTest(TestCase): + databases = "__all__" + + def setUp(self): + self.obj = PostgresArrayFieldModel.objects.create( + arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN], + ) + + @property + def latest_array_change(self): + return self.obj.history.latest().changes_display_dict["arrayfield"][1] + + def test_changes_display_dict_arrayfield(self): + self.assertEqual( + self.latest_array_change, + "Red, Green", + msg="The human readable text for the two choices, 'Red, Green' is displayed.", + ) + self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] + self.obj.save() + self.assertEqual( + self.latest_array_change, + "Green", + msg="The human readable text 'Green' is displayed.", + ) + self.obj.arrayfield = [] + self.obj.save() + self.assertEqual( + self.latest_array_change, + "", + msg="The human readable text '' is displayed.", + ) + self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] + self.obj.save() + self.assertEqual( + self.latest_array_change, + "Green", + msg="The human readable text 'Green' is displayed.", + ) diff --git a/auditlog_tests/test_settings.py b/auditlog_tests/test_settings.py index 3c4ca81..e7be7b8 100644 --- a/auditlog_tests/test_settings.py +++ b/auditlog_tests/test_settings.py @@ -8,6 +8,8 @@ DEBUG = True SECRET_KEY = "test" +TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3") + INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", @@ -20,6 +22,7 @@ INSTALLED_APPS = [ "test_app", ] + MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -28,18 +31,52 @@ MIDDLEWARE = [ "auditlog.middleware.AuditlogMiddleware", ] -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.getenv( - "TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "") - ), - "USER": os.getenv("TEST_DB_USER", "postgres"), - "PASSWORD": os.getenv("TEST_DB_PASS", ""), - "HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"), - "PORT": os.getenv("TEST_DB_PORT", "5432"), +if TEST_DB_BACKEND == "postgresql": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv( + "TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "") + ), + "USER": os.getenv("TEST_DB_USER", "postgres"), + "PASSWORD": os.getenv("TEST_DB_PASS", ""), + "HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"), + "PORT": os.getenv("TEST_DB_PORT", "5432"), + } } -} +elif TEST_DB_BACKEND == "mysql": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": os.getenv( + "TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "") + ), + "USER": os.getenv("TEST_DB_USER", "root"), + "PASSWORD": os.getenv("TEST_DB_PASS", ""), + "HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"), + "PORT": os.getenv("TEST_DB_PORT", "3306"), + "OPTIONS": { + "charset": "utf8mb4", + "init_command": "SET sql_mode='STRICT_TRANS_TABLES'", + }, + } + } +elif TEST_DB_BACKEND == "sqlite3": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.getenv( + "TEST_DB_NAME", + ( + ":memory:" + if os.getenv("TOX_PARALLEL_ENV") + else "test_auditlog.sqlite3" + ), + ), + } + } +else: + raise ValueError(f"Unsupported database backend: {TEST_DB_BACKEND}") TEMPLATES = [ { diff --git a/auditlog_tests/test_two_step_json_migration.py b/auditlog_tests/test_two_step_json_migration.py index 2c66bce..da1c19e 100644 --- a/auditlog_tests/test_two_step_json_migration.py +++ b/auditlog_tests/test_two_step_json_migration.py @@ -2,8 +2,10 @@ import json from io import StringIO from unittest.mock import patch +from django.conf import settings from django.core.management import CommandError, call_command from django.test import TestCase, override_settings +from django.test.utils import skipIf from test_app.models import SimpleModel from auditlog.models import LogEntry @@ -124,6 +126,7 @@ class AuditlogMigrateJsonTest(TestCase): # Assert self.assertEqual(call_count, 2) + @skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test") def test_native_postgres(self): # Arrange log_entry = self.make_logentry() @@ -136,6 +139,7 @@ class AuditlogMigrateJsonTest(TestCase): self.assertEqual(errbuf, "") self.assertIsNotNone(log_entry.changes) + @skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test") def test_native_postgres_changes_not_overwritten(self): # Arrange log_entry = self.make_logentry() diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index f3056bc..a6cc29d 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -42,7 +42,6 @@ from test_app.models import ( ModelPrimaryKeyModel, NoDeleteHistoryModel, NullableJSONModel, - PostgresArrayFieldModel, ProxyModel, RelatedModel, ReusableThroughRelatedModel, @@ -1717,47 +1716,6 @@ class CharFieldTextFieldModelTest(TestCase): ) -class PostgresArrayFieldModelTest(TestCase): - databases = "__all__" - - def setUp(self): - self.obj = PostgresArrayFieldModel.objects.create( - arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN], - ) - - @property - def latest_array_change(self): - return self.obj.history.latest().changes_display_dict["arrayfield"][1] - - def test_changes_display_dict_arrayfield(self): - self.assertEqual( - self.latest_array_change, - "Red, Green", - msg="The human readable text for the two choices, 'Red, Green' is displayed.", - ) - self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] - self.obj.save() - self.assertEqual( - self.latest_array_change, - "Green", - msg="The human readable text 'Green' is displayed.", - ) - self.obj.arrayfield = [] - self.obj.save() - self.assertEqual( - self.latest_array_change, - "", - msg="The human readable text '' is displayed.", - ) - self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] - self.obj.save() - self.assertEqual( - self.latest_array_change, - "Green", - msg="The human readable text 'Green' is displayed.", - ) - - class AdminPanelTest(TestCase): def setUp(self): self.user = User.objects.create_user( diff --git a/docs/requirements.txt b/docs/requirements.txt index eb4b4b6..251d463 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,3 +3,4 @@ django>=4.2,<4.3 sphinx sphinx_rtd_theme psycopg2-binary +mysqlclient==2.2.5 \ No newline at end of file diff --git a/runtests.sh b/runtests.sh new file mode 100755 index 0000000..14013db --- /dev/null +++ b/runtests.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Run tests against all supported databases +set -e + +# Default settings +export TEST_DB_USER=${TEST_DB_USER:-testuser} +export TEST_DB_PASS=${TEST_DB_PASS:-testpass} +export TEST_DB_HOST=${TEST_DB_HOST:-127.0.0.1} +export TEST_DB_NAME=${TEST_DB_NAME:-auditlog} + +# Cleanup on exit +trap 'docker compose -f auditlog_tests/docker-compose.yml down -v --remove-orphans 2>/dev/null || true' EXIT + +echo "Starting containers..." +docker compose -f auditlog_tests/docker-compose.yml up -d + +echo "Waiting for databases..." +echo "Waiting for PostgreSQL..." +until docker compose -f auditlog_tests/docker-compose.yml exec postgres pg_isready -U ${TEST_DB_USER} -d auditlog >/dev/null 2>&1; do + sleep 1 +done + +echo "Waiting for MySQL..." + +until docker compose -f auditlog_tests/docker-compose.yml exec mysql mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS} --silent >/dev/null 2>&1; do + sleep 1 +done +echo "Databases ready!" + +# Run tests for each database +for backend in sqlite3 postgresql mysql; do + echo "Testing $backend..." + export TEST_DB_BACKEND=$backend + case $backend in + postgresql) export TEST_DB_PORT=5432 ;; + mysql) export TEST_DB_PORT=3306;; + sqlite3) unset TEST_DB_PORT ;; + esac + tox +done + +echo "All tests completed!" diff --git a/tox.ini b/tox.ini index d9d9343..e804268 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ envlist = [testenv] setenv = - COVERAGE_FILE={toxworkdir}/.coverage.{envname} + COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND} changedir = auditlog_tests commands = coverage run --source auditlog ./manage.py test @@ -27,7 +27,10 @@ deps = codecov freezegun psycopg2-binary + mysqlclient + passenv= + TEST_DB_BACKEND TEST_DB_HOST TEST_DB_USER TEST_DB_PASS @@ -56,7 +59,15 @@ description = Check for missing migrations changedir = auditlog_tests deps = Django>=4.2 - psycopg2 + psycopg2-binary + mysqlclient +passenv= + TEST_DB_BACKEND + TEST_DB_HOST + TEST_DB_USER + TEST_DB_PASS + TEST_DB_NAME + TEST_DB_PORT commands = python manage.py makemigrations --check --dry-run