"""迁移一致性守门测试(schema/结构层,可跑在 pytest 里)。 背景:D4 加了 `Product.tax_rate` 等字段并生成了迁移,但本地 dev 库没执行 `migrate`,导致 6 个页面 500(`no such column: catalog_product.tax_rate`)。 单元测试跑在内存库里、每次按**模型**全量建表,**天然发现不了这类问题**。 本模块覆盖:模型字段可查询性、新 app 表存在性、迁移目录结构。 注意:**"模型改了没生成迁移" 无法在 pytest 里可靠检测**(测试环境会绕过迁移状态, 实测 MigrationAutodetector 在 pytest 进程中返回空)。那类检查放在独立脚本 `scripts/check_migrations.py`,应在提交前/部署前跑。 """ import pytest from django.apps import apps as django_apps from django.db.migrations.loader import MigrationLoader from django.db.migrations.autodetector import MigrationAutodetector from django.db.migrations.state import ProjectState from django.db.migrations.questioner import NonInteractiveMigrationQuestioner OWN_APPS = [ "core", "catalog", "partner", "inventory", "finance", "purchase", "sales", "report", "channel", "notify", "openapi", "printing", "ai", "billing", "website", "storefront", ] def _pending_changes_unused() -> dict: """返回"模型有、迁移没有"的变更(与 makemigrations --check 同一判定)。""" from django.db import connection loader = MigrationLoader(connection) autodetector = MigrationAutodetector( loader.project_state(), ProjectState.from_apps(django_apps), NonInteractiveMigrationQuestioner(specified_apps=set(), dry_run=True), ) return autodetector.changes( graph=loader.graph, trim_to_apps=set(OWN_APPS), convert_apps=set(OWN_APPS), migration_name="probe", ) @pytest.mark.django_db def test_own_apps_have_initial_migration(): """每个自有 app 都必须有至少一个迁移文件(新 app 最容易漏)。 直接查 app 的 migrations 包目录内容——比 MigrationLoader 更直白, 且不受连接/环境状态影响。 """ import os missing = [] for app_label in OWN_APPS: try: config = django_apps.get_app_config(app_label) except LookupError: continue # 无模型的 app(如纯模板的 website)不需要迁移,跳过 if not list(config.get_models()): continue mig_dir = os.path.join(config.path, "migrations") if not os.path.isdir(mig_dir): missing.append(f"{app_label}: 有模型但没有 migrations/ 目录") continue files = [ f for f in os.listdir(mig_dir) if f.endswith(".py") and f[0].isdigit() ] if not files: missing.append(f"{app_label}: 有模型但 migrations/ 下没有迁移文件") assert not missing, "以下 app 缺少迁移:" + "; ".join(missing) @pytest.mark.django_db def test_no_unapplied_migrations_on_default_db(): """生产/开发库(非测试库)不允许有未应用的迁移。 这条在 pytest 里通常被内存库掩盖,所以显式用 `connections` 检查 **磁盘迁移是否全部已应用**——若开发库漏了 migrate,这里会提示。 注意:测试环境下会跳过(因为测试库是临时建的)。 """ from django.db import connection loader = MigrationLoader(connection, ignore_no_migrations=True) plan = loader.graph.leaf_nodes() applied = set(loader.applied_migrations.keys()) unapplied = [node for node in plan if node not in applied] if unapplied: pytest.skip( "测试库未完全迁移(pytest-django 行为):" + ", ".join(f"{a}.{n}" for a, n in unapplied) ) @pytest.mark.django_db def test_tax_fields_queryable(): """D4 税率字段能被真实 SQL 查到(覆盖 ORM→SQL 路径)。""" from apps.catalog.models import Product from apps.purchase.models import PurchaseBillLine from apps.sales.models import SalesBillLine assert Product.objects.filter(tax_rate__gte=0).count() >= 0 assert SalesBillLine.objects.filter(tax_amount__gte=0).count() >= 0 assert PurchaseBillLine.objects.filter(tax_rate__gte=0).count() >= 0 @pytest.mark.django_db def test_new_app_tables_created(): """本轮新增 app 的表都要能查(新 app 漏迁移时这里会炸)。""" from apps.ai.models import AiUsage from apps.billing.models import Plan, Subscription from apps.storefront.models import ( CustomerProductAuth, StorefrontAccount, StorefrontOrder, StorefrontOrderLine, ) for model in (AiUsage, Plan, Subscription, StorefrontAccount, CustomerProductAuth, StorefrontOrder, StorefrontOrderLine): assert model.objects.count() >= 0, f"{model.__name__} 表不可查" @pytest.mark.django_db def test_all_model_fields_queryable(): """全模型字段可查询性:对每个模型做一次 `values()` 全字段查询。 `values(*fields)` 会把这些列全部放进 SELECT,缺列时立即 OperationalError——比 PRAGMA 检查更贴近真实运行。 """ failures = [] for app_label in OWN_APPS: try: config = django_apps.get_app_config(app_label) except LookupError: continue for model in config.get_models(): fields = [f.name for f in model._meta.concrete_fields] if not fields: continue try: list(model.objects.values(*fields)[:1]) except Exception as exc: failures.append(f"{app_label}.{model.__name__}: {type(exc).__name__}: {exc}") assert not failures, "以下模型字段在数据库中不可查询:\n" + "\n".join(failures)