import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('..', import.meta.url)); const read = (path) => readFileSync(join(root, path), 'utf8'); for (const suffix of ['up', 'verify', 'down']) { assert.ok( existsSync(join(root, `database/migrations/2026081005_m09b_cleaning_rules.${suffix}.sql`)), `M09-B ${suffix} migration is missing` ); } const migration = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql'); for (const pattern of [ 'qipai_cleaning_templates', 'cleaning_template_version', 'photo_required', 'min_photo_count', 'max_photo_count', 'exempt_policy', 'rework_count', 'qipai_cleaning_task_photos', 'retention_until', 'attached_revision', 'qipai_cleaning_task_submissions', 'uq_qipai_cleaning_submission_revision' ]) assert.ok(migration.includes(pattern), `migration is missing ${pattern}`); const repository = read('backend/src/cleaning/cleaning-task-repository.ts'); for (const pattern of [ 'listTemplates', 'upsertTemplate', "'appliesToExistingTasks', FALSE", 'listSubmissions', 'CLEANING_PHOTO_OWNERSHIP_INVALID', "status = 'ATTACHED'", 'attached_revision = ?', 'claimExpiredPhotoUploads', 'markPhotoUploadsDeleted', 'CLEANING_EXEMPT_POLICY_DENIED', "ORDER BY FIELD(scope_type, 'ROOM', 'STORE', 'TENANT')" ]) assert.ok(repository.includes(pattern), `repository is missing ${pattern}`); const routes = read('backend/src/routes/cleaning.ts'); for (const pattern of [ '/admin-api/cleaning/templates', '/admin-api/cleaning/tasks/:taskId/submissions', '/admin-api/cleaning/photos/cleanup', 'tenantId: actor.tenantId', 'storeId: context.storeId', 'deleteImage(image.storagePath)' ]) assert.ok(routes.includes(pattern), `cleaning routes are missing ${pattern}`); const media = read('backend/src/content/media-storage.ts'); for (const pattern of [ 'declaredFormat', 'declaredExtension', 'metadata.format', '.webp({ quality: 82 })', 'deleteImage' ]) assert.ok(media.includes(pattern), `media storage is missing ${pattern}`); const automation = read('backend/src/devices/order-device-automation-service.ts'); for (const pattern of [ 'ROOM_HAS_ACTIVE_ORDER', "status IN ('PAID', 'RESERVED', 'IN_PROGRESS')", 'room_id = ? AND id <> ?' ]) assert.ok(automation.includes(pattern), `device automation is missing ${pattern}`); const miniapp = read('miniapp/pages/cleaner/tasks.js') + read('miniapp/pages/cleaner/tasks.wxml'); for (const pattern of [ 'minPhotoCount', 'maxPhotoCount', "task.status === 'STARTED'", 'photoRequired' ]) assert.ok(miniapp.includes(pattern), `cleaner miniapp is missing ${pattern}`); const admin = read('admin/src/components/CleaningRulesPanel.vue') + read('admin/src/components/CleaningTasksPanel.vue') + read('admin/src/api.ts'); for (const pattern of [ '配置变更不会静默改写在途任务', 'listCleaningTemplates', 'upsertCleaningTemplate', 'listCleaningTaskSubmissions', '验收照片版本', 'submission.revision' ]) assert.ok(admin.includes(pattern), `admin cleaning workflow is missing ${pattern}`); const liveTest = read('backend/tests/mysql-migration-roundtrip.test.mjs'); for (const pattern of [ 'cleaning template snapshot remains stable after config version change', 'rejected and accepted cleaning photo revisions remain distinguishable', 'expired unattached cleaning photo retention cleanup', 'task-level cleaning exemption policy denial' ]) assert.ok(liveTest.includes(pattern), `live MySQL test is missing ${pattern}`); console.log('PASS: M09-B cleaning templates, photo security, versioned review and safe device shutdown gates are present.');