diff --git a/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md b/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md index be907b9..913da47 100644 --- a/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md +++ b/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md @@ -857,23 +857,23 @@ Actual: Admin repo remained clean after Task 2.2 verification, so Batch 2 create - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\wordpress\**` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\asset\**` -- [ ] **Step 1: Write RED tests for public Blog behavior** +- [x] **Step 1: Write RED tests for public Blog behavior** Cover article list, article detail, term relation, and theme profile. -- [ ] **Step 2: Write RED tests for WordPress mapping** +- [x] **Step 2: Write RED tests for WordPress mapping** Cover remote ID to local post/term mapping and sync job state. -- [ ] **Step 3: Write RED tests for Asset ownership** +- [x] **Step 3: Write RED tests for Asset ownership** Cover object owner module, MIME metadata, reference, and access grant. -- [ ] **Step 4: Implement modules** +- [x] **Step 4: Implement modules** Keep route compatibility for public Blog endpoints and Admin-facing endpoints. Move MinIO ownership out of legacy `src/minio` into `src/modules/asset`. -- [ ] **Step 5: Verify** +- [x] **Step 5: Verify** Run: @@ -884,6 +884,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck Expected: PASS. +Actual note: Batch 3 moved API ownership to transitional `BlogContentModule`, `WordpressMirrorModule`, and `AssetModule` boundaries while retaining legacy business internals behind imported modules for compatibility. Contract tests now parse `sql/refactor-v3/00-full-schema.sql` through `test/helpers/sql-schema.helper.ts` so domain expectations catch SQL schema drift instead of only repeating local constants. Route compatibility and duplicate-controller safety are covered by module graph tests, with `QqbotModule` mocked in metadata-only specs to avoid unrelated BangDream/NapCat side effects. + ### Task 3.2: Sync Admin Blog/WordPress/Asset pages **Files:** @@ -893,15 +895,15 @@ Expected: PASS. - Modify: `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\blog\**` - Modify: `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\router\routes\modules\blog.ts` -- [ ] **Step 1: Update callers** +- [x] **Step 1: Update callers** Use string IDs and explicit response types. -- [ ] **Step 2: Keep pages work-focused** +- [x] **Step 2: Keep pages work-focused** Use KtTable or existing forms; avoid landing-page or card-heavy redesign. -- [ ] **Step 3: Verify** +- [x] **Step 3: Verify** Run: @@ -911,6 +913,8 @@ pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin -F @vben/web-antdv-next run typec Expected: PASS. +Actual note: Admin Blog/WordPress ID-facing types were aligned to string IDs, `api/blog/asset.ts` was added against the existing `/minio/*` compatibility routes, and the Blog router/page layout was left unchanged because Batch 3 did not introduce a new visible page structure. + ### Task 3.3: Commit Batch 3 Run: diff --git a/src/app.module.ts b/src/app.module.ts index 635c37f..2f07bd3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,7 +6,6 @@ import { LoggerModule } from 'nestjs-pino'; import { AppService } from './app.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MinioModule } from 'nestjs-minio-client'; -import { MinioClientModule } from './minio/minio.module'; import { ApiRequestLogInterceptor, ApiExceptionFilter, @@ -15,8 +14,9 @@ import { SaveBodyInterceptor, } from './common'; import { AdminModule } from './modules/admin/admin.module'; -import { BlogModule } from './blog/blog.module'; -import { WordpressModule } from './wordpress/wordpress.module'; +import { AssetModule } from './modules/asset/asset.module'; +import { BlogContentModule } from './modules/blog/blog-content.module'; +import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module'; import { QqbotModule } from './qqbot/qqbot.module'; import { RuntimeModule } from './runtime'; @@ -63,12 +63,12 @@ import { RuntimeModule } from './runtime'; }, inject: [ConfigService], }), - MinioClientModule, CommonModule, RuntimeModule, AdminModule, - BlogModule, - WordpressModule, + BlogContentModule, + WordpressMirrorModule, + AssetModule, QqbotModule, ], providers: [ diff --git a/src/modules/admin/identity/admin-identity.module.ts b/src/modules/admin/identity/admin-identity.module.ts index 5fcf6dc..664023e 100644 --- a/src/modules/admin/identity/admin-identity.module.ts +++ b/src/modules/admin/identity/admin-identity.module.ts @@ -15,7 +15,7 @@ import { AdminUserManageController } from '@/admin/user/admin-user-manage.contro import { AdminUserController } from '@/admin/user/admin-user.controller'; import { AdminUser } from '@/admin/user/admin-user.entity'; import { AdminUserService } from '@/admin/user/admin-user.service'; -import { WordpressModule } from '@/wordpress/wordpress.module'; +import { WordpressMirrorModule } from '@/modules/wordpress/wordpress-mirror.module'; export const ADMIN_IDENTITY_CONTROLLERS = [ AdminAuthController, @@ -37,7 +37,7 @@ export const ADMIN_IDENTITY_PROVIDERS = [ imports: [ TypeOrmModule.forFeature([AdminUser, AdminRole, AdminMenu, AdminDept]), AdminAuthGuardModule, - WordpressModule, + WordpressMirrorModule, ], controllers: ADMIN_IDENTITY_CONTROLLERS, providers: ADMIN_IDENTITY_PROVIDERS, diff --git a/src/modules/admin/platform-config/admin-platform-config.module.ts b/src/modules/admin/platform-config/admin-platform-config.module.ts index 9f753b1..0f3de52 100644 --- a/src/modules/admin/platform-config/admin-platform-config.module.ts +++ b/src/modules/admin/platform-config/admin-platform-config.module.ts @@ -14,7 +14,7 @@ import { AdminTimezoneController } from '@/admin/timezone/admin-timezone.control import { AdminTimezoneService } from '@/admin/timezone/admin-timezone.service'; import { AdminUser } from '@/admin/user/admin-user.entity'; import { AdminExampleController } from '@/admin/example/admin-example.controller'; -import { MinioClientModule } from '@/minio/minio.module'; +import { AssetModule } from '@/modules/asset/asset.module'; export const ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS = [ ComponentController, @@ -45,7 +45,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [ AdminAuthGuardModule, DictModule, NoticeModule, - MinioClientModule, + AssetModule, ], controllers: ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS, providers: ADMIN_PLATFORM_CONFIG_PROVIDERS, diff --git a/src/modules/asset/asset.module.ts b/src/modules/asset/asset.module.ts new file mode 100644 index 0000000..ae00c35 --- /dev/null +++ b/src/modules/asset/asset.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common'; +import { MinioClientController } from '@/minio/minio.controller'; +import { MinioClientModule } from '@/minio/minio.module'; +import { MinioClientService } from '@/minio/minio.service'; + +export const ASSET_CONTROLLERS = [MinioClientController]; + +export const ASSET_PROVIDERS = [MinioClientService]; + +export const ASSET_DOMAIN_CONTRACT = { + tables: [ + 'asset_bucket', + 'asset_object', + 'asset_reference', + 'asset_access_grant', + ], + objectOwnership: { + objectTable: 'asset_object', + bucketTable: 'asset_bucket', + ownerModuleField: 'source_module', + objectKeyField: 'object_key', + }, + mimeMetadata: { + objectTable: 'asset_object', + mimeField: 'mime_type', + sizeField: 'size_bytes', + metadataField: 'metadata_json', + }, + reference: { + table: 'asset_reference', + objectKey: 'object_id', + ownerFields: ['owner_module', 'owner_type', 'owner_id'], + }, + accessGrant: { + table: 'asset_access_grant', + objectKey: 'object_id', + tokenField: 'grant_token', + expiresAtField: 'expires_at', + statusField: 'status', + }, +} as const; + +@Module({ + imports: [MinioClientModule], + exports: [MinioClientModule], +}) +export class AssetModule {} diff --git a/src/modules/blog/blog-content.module.ts b/src/modules/blog/blog-content.module.ts new file mode 100644 index 0000000..8bdfe70 --- /dev/null +++ b/src/modules/blog/blog-content.module.ts @@ -0,0 +1,66 @@ +import { Module } from '@nestjs/common'; +import { BlogArticleController } from '@/blog/blog-article.controller'; +import { BlogArticleService } from '@/blog/blog-article.service'; +import { BlogModule } from '@/blog/blog.module'; +import { BlogTermController } from '@/blog/blog-term.controller'; +import { BlogTermService } from '@/blog/blog-term.service'; +import { BlogThemeConfigController } from '@/blog/blog-theme-config.controller'; +import { BlogThemeConfigService } from '@/blog/blog-theme-config.service'; + +export const BLOG_CONTENT_CONTROLLERS = [ + BlogArticleController, + BlogTermController, + BlogThemeConfigController, +]; + +export const BLOG_CONTENT_PROVIDERS = [ + BlogArticleService, + BlogTermService, + BlogThemeConfigService, +]; + +export const BLOG_CONTENT_DOMAIN_CONTRACT = { + tables: [ + 'blog_post', + 'blog_taxonomy', + 'blog_term', + 'blog_post_term', + 'blog_theme_profile', + 'blog_import_job', + ], + publicArticleList: { + route: 'GET /blog/article/public/list', + sourceTable: 'blog_post', + statusField: 'status', + publishTimeField: 'publish_time', + }, + publicArticleDetail: { + route: 'GET /blog/article/public/detail', + sourceTable: 'blog_post', + lookupFields: ['id', 'slug'], + }, + termRelation: { + relationTable: 'blog_post_term', + postKey: 'post_id', + termKey: 'term_id', + taxonomyTables: ['blog_taxonomy', 'blog_term'], + }, + themeProfile: { + table: 'blog_theme_profile', + profileKey: 'profile_key', + configField: 'config_json', + enabledField: 'enabled', + }, + importJob: { + table: 'blog_import_job', + sourceField: 'source_key', + statusField: 'status', + summaryField: 'summary_json', + }, +} as const; + +@Module({ + imports: [BlogModule], + exports: [BlogModule], +}) +export class BlogContentModule {} diff --git a/src/modules/wordpress/wordpress-mirror.module.ts b/src/modules/wordpress/wordpress-mirror.module.ts new file mode 100644 index 0000000..2c40753 --- /dev/null +++ b/src/modules/wordpress/wordpress-mirror.module.ts @@ -0,0 +1,59 @@ +import { Module } from '@nestjs/common'; +import { WordpressArticleController } from '@/wordpress/wordpress-article.controller'; +import { WordpressAuthController } from '@/wordpress/wordpress-auth.controller'; +import { WordpressCategoryController } from '@/wordpress/wordpress-category.controller'; +import { WordpressModule } from '@/wordpress/wordpress.module'; +import { WordpressService } from '@/wordpress/wordpress.service'; +import { WordpressTagController } from '@/wordpress/wordpress-tag.controller'; +import { WordpressThemeController } from '@/wordpress/wordpress-theme.controller'; + +export const WORDPRESS_MIRROR_CONTROLLERS = [ + WordpressAuthController, + WordpressArticleController, + WordpressTagController, + WordpressCategoryController, + WordpressThemeController, +]; + +export const WORDPRESS_MIRROR_PROVIDERS = [WordpressService]; + +export const WORDPRESS_MIRROR_DOMAIN_CONTRACT = { + tables: [ + 'wordpress_site', + 'wordpress_auth_session', + 'wordpress_remote_post', + 'wordpress_remote_term', + 'wordpress_sync_job', + 'wordpress_sync_mapping', + ], + remotePostMapping: { + remoteTable: 'wordpress_remote_post', + mappingTable: 'wordpress_sync_mapping', + remoteType: 'post', + localType: 'blog_post', + remoteKeys: ['site_id', 'remote_id'], + localKey: 'local_id', + }, + remoteTermMapping: { + remoteTable: 'wordpress_remote_term', + mappingTable: 'wordpress_sync_mapping', + remoteType: 'term', + localType: 'blog_term', + remoteKeys: ['site_id', 'taxonomy_key', 'remote_id'], + localKey: 'local_id', + }, + syncJob: { + table: 'wordpress_sync_job', + jobTypeField: 'job_type', + statusField: 'status', + startedAtField: 'started_at', + finishedAtField: 'finished_at', + summaryField: 'summary_json', + }, +} as const; + +@Module({ + imports: [WordpressModule], + exports: [WordpressModule], +}) +export class WordpressMirrorModule {} diff --git a/test/helpers/sql-schema.helper.ts b/test/helpers/sql-schema.helper.ts new file mode 100644 index 0000000..dac5276 --- /dev/null +++ b/test/helpers/sql-schema.helper.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const createTableRegex = + /CREATE TABLE IF NOT EXISTS\s+([A-Za-z0-9_]+)\s*\(([\s\S]*?)\)\s*ENGINE=/gi; + +const nonColumnTokens = new Set([ + 'CONSTRAINT', + 'FOREIGN', + 'KEY', + 'PRIMARY', + 'UNIQUE', +]); + +export type SqlSchemaContract = { + hasTable(tableName: string): boolean; + expectTableColumns(tableName: string, columns: readonly string[]): void; +}; + +const parseColumnNames = (tableBlock: string) => { + return tableBlock + .split(/\r?\n/) + .map((line) => line.trim().replace(/,$/, '')) + .flatMap((line) => { + const match = line.match(/^`?([A-Za-z_][A-Za-z0-9_]*)`?\s+/); + + if (!match || nonColumnTokens.has(match[1].toUpperCase())) { + return []; + } + + return [match[1]]; + }); +}; + +const parseSchema = (sql: string) => { + const tables = new Map>(); + + for (const match of sql.matchAll(createTableRegex)) { + const [, tableName, tableBlock] = match; + tables.set(tableName, new Set(parseColumnNames(tableBlock))); + } + + return tables; +}; + +export const readRefactorV3SqlSchema = (): SqlSchemaContract => { + const schemaPath = path.resolve( + __dirname, + '..', + '..', + 'sql', + 'refactor-v3', + '00-full-schema.sql', + ); + const tables = parseSchema(fs.readFileSync(schemaPath, 'utf8')); + + return { + hasTable: (tableName) => tables.has(tableName), + expectTableColumns: (tableName, columns) => { + const tableColumns = tables.get(tableName); + + expect(tableColumns).toBeDefined(); + expect(Array.from(tableColumns || [])).toEqual( + expect.arrayContaining([...columns]), + ); + }, + }; +}; diff --git a/test/modules/asset/asset-module-contract.spec.ts b/test/modules/asset/asset-module-contract.spec.ts new file mode 100644 index 0000000..316f4eb --- /dev/null +++ b/test/modules/asset/asset-module-contract.spec.ts @@ -0,0 +1,164 @@ +jest.mock('../../../src/qqbot/qqbot.module', () => ({ + QqbotModule: class QqbotModule {}, +})); + +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { AppModule } from '../../../src/app.module'; +import { MinioClientController } from '../../../src/minio/minio.controller'; +import { MinioClientModule } from '../../../src/minio/minio.module'; +import { MinioClientService } from '../../../src/minio/minio.service'; +import { + ASSET_CONTROLLERS, + ASSET_DOMAIN_CONTRACT, + ASSET_PROVIDERS, + AssetModule, +} from '../../../src/modules/asset/asset.module'; +import { AdminPlatformConfigModule } from '../../../src/modules/admin/platform-config/admin-platform-config.module'; +import { + collectControllerRoutes, + routeKey, +} from '../../helpers/controller-route.helper'; +import { readRefactorV3SqlSchema } from '../../helpers/sql-schema.helper'; + +const getModuleMetadata = (moduleClass: unknown, key: string): T[] => { + return Reflect.getMetadata(key, moduleClass) || []; +}; + +const expectControllersNotRegisteredDirectly = ( + moduleClass: unknown, + controllers: unknown[], +) => { + const directControllers = getModuleMetadata( + moduleClass, + MODULE_METADATA.CONTROLLERS, + ); + + for (const controller of controllers) { + expect(directControllers).not.toContain(controller); + } +}; + +describe('Asset module contract', () => { + const schema = readRefactorV3SqlSchema(); + + it('keeps MinIO-backed asset routes compatible through the Asset boundary', () => { + const routes = collectControllerRoutes(ASSET_CONTROLLERS); + + expect(routes.map(routeKey)).toEqual( + expect.arrayContaining([ + 'GET /minio/check', + 'POST /minio/bucket', + 'POST /minio/upload', + 'GET /minio/list', + 'GET /minio/url', + 'GET /minio/resource-proxy', + 'GET /minio/download', + 'DELETE /minio/remove', + ]), + ); + }); + + it('routes MinIO through the Asset module boundary without duplicate direct controllers', () => { + expect(getModuleMetadata(AppModule, MODULE_METADATA.IMPORTS)).toEqual( + expect.arrayContaining([AssetModule]), + ); + expect(getModuleMetadata(AppModule, MODULE_METADATA.IMPORTS)).not.toEqual( + expect.arrayContaining([MinioClientModule]), + ); + expect( + getModuleMetadata(AdminPlatformConfigModule, MODULE_METADATA.IMPORTS), + ).toEqual(expect.arrayContaining([AssetModule])); + expect( + getModuleMetadata(AdminPlatformConfigModule, MODULE_METADATA.IMPORTS), + ).not.toEqual(expect.arrayContaining([MinioClientModule])); + + expect(getModuleMetadata(AssetModule, MODULE_METADATA.IMPORTS)).toEqual( + expect.arrayContaining([MinioClientModule]), + ); + expect(getModuleMetadata(AssetModule, MODULE_METADATA.EXPORTS)).toEqual( + expect.arrayContaining([MinioClientModule]), + ); + expectControllersNotRegisteredDirectly(AssetModule, ASSET_CONTROLLERS); + + expect( + getModuleMetadata(MinioClientModule, MODULE_METADATA.CONTROLLERS), + ).toEqual(expect.arrayContaining([MinioClientController])); + expect( + getModuleMetadata(MinioClientModule, MODULE_METADATA.PROVIDERS), + ).toEqual(expect.arrayContaining([MinioClientService])); + expect(ASSET_CONTROLLERS).toEqual( + expect.arrayContaining([MinioClientController]), + ); + expect(ASSET_PROVIDERS).toEqual( + expect.arrayContaining([MinioClientService]), + ); + }); + + it('matches the real Batch 3 Asset SQL schema and boundary contract', () => { + expect(ASSET_DOMAIN_CONTRACT.tables).toEqual([ + 'asset_bucket', + 'asset_object', + 'asset_reference', + 'asset_access_grant', + ]); + for (const table of ASSET_DOMAIN_CONTRACT.tables) { + expect(schema.hasTable(table)).toBe(true); + } + + schema.expectTableColumns('asset_bucket', [ + 'id', + 'bucket_key', + 'bucket_name', + 'provider', + 'status', + ]); + schema.expectTableColumns('asset_object', [ + 'id', + 'bucket_id', + 'object_key', + 'source_module', + 'mime_type', + 'size_bytes', + 'metadata_json', + ]); + schema.expectTableColumns('asset_reference', [ + 'id', + 'object_id', + 'owner_module', + 'owner_type', + 'owner_id', + ]); + schema.expectTableColumns('asset_access_grant', [ + 'id', + 'object_id', + 'grant_token', + 'expires_at', + 'status', + ]); + + expect(ASSET_DOMAIN_CONTRACT.objectOwnership).toEqual({ + objectTable: 'asset_object', + bucketTable: 'asset_bucket', + ownerModuleField: 'source_module', + objectKeyField: 'object_key', + }); + expect(ASSET_DOMAIN_CONTRACT.mimeMetadata).toEqual({ + objectTable: 'asset_object', + mimeField: 'mime_type', + sizeField: 'size_bytes', + metadataField: 'metadata_json', + }); + expect(ASSET_DOMAIN_CONTRACT.reference).toEqual({ + table: 'asset_reference', + objectKey: 'object_id', + ownerFields: ['owner_module', 'owner_type', 'owner_id'], + }); + expect(ASSET_DOMAIN_CONTRACT.accessGrant).toEqual({ + table: 'asset_access_grant', + objectKey: 'object_id', + tokenField: 'grant_token', + expiresAtField: 'expires_at', + statusField: 'status', + }); + }); +}); diff --git a/test/modules/blog/blog-module-contract.spec.ts b/test/modules/blog/blog-module-contract.spec.ts new file mode 100644 index 0000000..a94aa2a --- /dev/null +++ b/test/modules/blog/blog-module-contract.spec.ts @@ -0,0 +1,204 @@ +jest.mock('../../../src/qqbot/qqbot.module', () => ({ + QqbotModule: class QqbotModule {}, +})); + +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { AppModule } from '../../../src/app.module'; +import { BlogArticleController } from '../../../src/blog/blog-article.controller'; +import { BlogArticleService } from '../../../src/blog/blog-article.service'; +import { BlogModule } from '../../../src/blog/blog.module'; +import { BlogTermController } from '../../../src/blog/blog-term.controller'; +import { BlogTermService } from '../../../src/blog/blog-term.service'; +import { BlogThemeConfigController } from '../../../src/blog/blog-theme-config.controller'; +import { BlogThemeConfigService } from '../../../src/blog/blog-theme-config.service'; +import { + BLOG_CONTENT_CONTROLLERS, + BLOG_CONTENT_DOMAIN_CONTRACT, + BLOG_CONTENT_PROVIDERS, + BlogContentModule, +} from '../../../src/modules/blog/blog-content.module'; +import { + collectControllerRoutes, + routeKey, +} from '../../helpers/controller-route.helper'; +import { readRefactorV3SqlSchema } from '../../helpers/sql-schema.helper'; + +const getModuleMetadata = (moduleClass: unknown, key: string): T[] => { + return Reflect.getMetadata(key, moduleClass) || []; +}; + +const expectControllersNotRegisteredDirectly = ( + moduleClass: unknown, + controllers: unknown[], +) => { + const directControllers = getModuleMetadata( + moduleClass, + MODULE_METADATA.CONTROLLERS, + ); + + for (const controller of controllers) { + expect(directControllers).not.toContain(controller); + } +}; + +describe('Blog content module contract', () => { + const schema = readRefactorV3SqlSchema(); + + it('keeps public and Admin-facing Blog routes compatible', () => { + const routes = collectControllerRoutes(BLOG_CONTENT_CONTROLLERS); + + expect(routes.map(routeKey)).toEqual( + expect.arrayContaining([ + 'GET /blog/article/public/list', + 'GET /blog/article/public/detail', + 'GET /blog/article/list', + 'GET /blog/article/detail', + 'POST /blog/article/save', + 'POST /blog/article/update', + 'POST /blog/article/remove', + 'GET /blog/article/category-options', + 'GET /blog/article/tag-options', + 'POST /blog/article/import-wordpress', + 'GET /blog/category/list', + 'GET /blog/category/detail', + 'POST /blog/category/save', + 'POST /blog/category/update', + 'POST /blog/category/remove', + 'GET /blog/tag/list', + 'GET /blog/tag/detail', + 'POST /blog/tag/save', + 'POST /blog/tag/update', + 'POST /blog/tag/remove', + 'GET /blog/term/options', + 'GET /blog/theme/config', + 'POST /blog/theme/save', + 'POST /blog/theme/import-wordpress', + ]), + ); + }); + + it('routes Blog through the new module boundary without duplicate direct controllers', () => { + expect(getModuleMetadata(AppModule, MODULE_METADATA.IMPORTS)).toEqual( + expect.arrayContaining([BlogContentModule]), + ); + expect(getModuleMetadata(AppModule, MODULE_METADATA.IMPORTS)).not.toEqual( + expect.arrayContaining([BlogModule]), + ); + + expect( + getModuleMetadata(BlogContentModule, MODULE_METADATA.IMPORTS), + ).toEqual(expect.arrayContaining([BlogModule])); + expect( + getModuleMetadata(BlogContentModule, MODULE_METADATA.EXPORTS), + ).toEqual(expect.arrayContaining([BlogModule])); + expectControllersNotRegisteredDirectly( + BlogContentModule, + BLOG_CONTENT_CONTROLLERS, + ); + + expect(getModuleMetadata(BlogModule, MODULE_METADATA.CONTROLLERS)).toEqual( + expect.arrayContaining([ + BlogArticleController, + BlogTermController, + BlogThemeConfigController, + ]), + ); + expect(getModuleMetadata(BlogModule, MODULE_METADATA.PROVIDERS)).toEqual( + expect.arrayContaining([ + BlogArticleService, + BlogTermService, + BlogThemeConfigService, + ]), + ); + expect(BLOG_CONTENT_CONTROLLERS).toEqual( + expect.arrayContaining([ + BlogArticleController, + BlogTermController, + BlogThemeConfigController, + ]), + ); + expect(BLOG_CONTENT_PROVIDERS).toEqual( + expect.arrayContaining([ + BlogArticleService, + BlogTermService, + BlogThemeConfigService, + ]), + ); + }); + + it('matches the real Batch 3 Blog content SQL schema and public surface contract', () => { + expect(BLOG_CONTENT_DOMAIN_CONTRACT.tables).toEqual([ + 'blog_post', + 'blog_taxonomy', + 'blog_term', + 'blog_post_term', + 'blog_theme_profile', + 'blog_import_job', + ]); + for (const table of BLOG_CONTENT_DOMAIN_CONTRACT.tables) { + expect(schema.hasTable(table)).toBe(true); + } + + schema.expectTableColumns('blog_post', [ + 'id', + 'slug', + 'title', + 'status', + 'publish_time', + ]); + schema.expectTableColumns('blog_taxonomy', [ + 'id', + 'taxonomy_key', + 'taxonomy_name', + ]); + schema.expectTableColumns('blog_term', [ + 'id', + 'taxonomy_id', + 'slug', + 'term_name', + ]); + schema.expectTableColumns('blog_post_term', ['id', 'post_id', 'term_id']); + schema.expectTableColumns('blog_theme_profile', [ + 'id', + 'profile_key', + 'config_json', + 'enabled', + ]); + schema.expectTableColumns('blog_import_job', [ + 'id', + 'source_key', + 'status', + 'summary_json', + ]); + + expect(BLOG_CONTENT_DOMAIN_CONTRACT.publicArticleList).toEqual({ + route: 'GET /blog/article/public/list', + sourceTable: 'blog_post', + statusField: 'status', + publishTimeField: 'publish_time', + }); + expect(BLOG_CONTENT_DOMAIN_CONTRACT.publicArticleDetail).toEqual({ + route: 'GET /blog/article/public/detail', + sourceTable: 'blog_post', + lookupFields: ['id', 'slug'], + }); + expect(BLOG_CONTENT_DOMAIN_CONTRACT.termRelation).toEqual({ + relationTable: 'blog_post_term', + postKey: 'post_id', + termKey: 'term_id', + taxonomyTables: ['blog_taxonomy', 'blog_term'], + }); + expect(BLOG_CONTENT_DOMAIN_CONTRACT.themeProfile).toEqual({ + table: 'blog_theme_profile', + profileKey: 'profile_key', + configField: 'config_json', + enabledField: 'enabled', + }); + expect(BLOG_CONTENT_DOMAIN_CONTRACT.importJob).toEqual({ + table: 'blog_import_job', + sourceField: 'source_key', + statusField: 'status', + summaryField: 'summary_json', + }); + }); +}); diff --git a/test/modules/wordpress/wordpress-module-contract.spec.ts b/test/modules/wordpress/wordpress-module-contract.spec.ts new file mode 100644 index 0000000..9d4c828 --- /dev/null +++ b/test/modules/wordpress/wordpress-module-contract.spec.ts @@ -0,0 +1,216 @@ +jest.mock('../../../src/qqbot/qqbot.module', () => ({ + QqbotModule: class QqbotModule {}, +})); + +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { AppModule } from '../../../src/app.module'; +import { AdminIdentityModule } from '../../../src/modules/admin/identity/admin-identity.module'; +import { + WORDPRESS_MIRROR_CONTROLLERS, + WORDPRESS_MIRROR_DOMAIN_CONTRACT, + WORDPRESS_MIRROR_PROVIDERS, + WordpressMirrorModule, +} from '../../../src/modules/wordpress/wordpress-mirror.module'; +import { WordpressArticleController } from '../../../src/wordpress/wordpress-article.controller'; +import { WordpressAuthController } from '../../../src/wordpress/wordpress-auth.controller'; +import { WordpressCategoryController } from '../../../src/wordpress/wordpress-category.controller'; +import { WordpressModule } from '../../../src/wordpress/wordpress.module'; +import { WordpressService } from '../../../src/wordpress/wordpress.service'; +import { WordpressTagController } from '../../../src/wordpress/wordpress-tag.controller'; +import { WordpressThemeController } from '../../../src/wordpress/wordpress-theme.controller'; +import { + collectControllerRoutes, + routeKey, +} from '../../helpers/controller-route.helper'; +import { readRefactorV3SqlSchema } from '../../helpers/sql-schema.helper'; + +const getModuleMetadata = (moduleClass: unknown, key: string): T[] => { + return Reflect.getMetadata(key, moduleClass) || []; +}; + +const expectControllersNotRegisteredDirectly = ( + moduleClass: unknown, + controllers: unknown[], +) => { + const directControllers = getModuleMetadata( + moduleClass, + MODULE_METADATA.CONTROLLERS, + ); + + for (const controller of controllers) { + expect(directControllers).not.toContain(controller); + } +}; + +describe('WordPress mirror module contract', () => { + const schema = readRefactorV3SqlSchema(); + + it('keeps public and Admin-facing WordPress routes compatible', () => { + const routes = collectControllerRoutes(WORDPRESS_MIRROR_CONTROLLERS); + + expect(routes.map(routeKey)).toEqual( + expect.arrayContaining([ + 'POST /wordpress/auth/login', + 'POST /wordpress/auth/logout', + 'GET /wordpress/auth/check', + 'GET /wordpress/article/public/list', + 'GET /wordpress/article/public/detail', + 'GET /wordpress/article/list', + 'GET /wordpress/article/detail', + 'POST /wordpress/article/save', + 'POST /wordpress/article/update', + 'POST /wordpress/article/remove', + 'GET /wordpress/tag/list', + 'GET /wordpress/tag/detail', + 'POST /wordpress/tag/save', + 'POST /wordpress/tag/update', + 'POST /wordpress/tag/remove', + 'GET /wordpress/category/list', + 'GET /wordpress/category/detail', + 'POST /wordpress/category/save', + 'POST /wordpress/category/update', + 'POST /wordpress/category/remove', + 'GET /wordpress/theme/config', + ]), + ); + }); + + it('routes WordPress through the new module boundary without duplicate direct controllers', () => { + expect(getModuleMetadata(AppModule, MODULE_METADATA.IMPORTS)).toEqual( + expect.arrayContaining([WordpressMirrorModule]), + ); + expect(getModuleMetadata(AppModule, MODULE_METADATA.IMPORTS)).not.toEqual( + expect.arrayContaining([WordpressModule]), + ); + expect( + getModuleMetadata(AdminIdentityModule, MODULE_METADATA.IMPORTS), + ).toEqual(expect.arrayContaining([WordpressMirrorModule])); + expect( + getModuleMetadata(AdminIdentityModule, MODULE_METADATA.IMPORTS), + ).not.toEqual(expect.arrayContaining([WordpressModule])); + + expect( + getModuleMetadata(WordpressMirrorModule, MODULE_METADATA.IMPORTS), + ).toEqual(expect.arrayContaining([WordpressModule])); + expect( + getModuleMetadata(WordpressMirrorModule, MODULE_METADATA.EXPORTS), + ).toEqual(expect.arrayContaining([WordpressModule])); + expectControllersNotRegisteredDirectly( + WordpressMirrorModule, + WORDPRESS_MIRROR_CONTROLLERS, + ); + + expect( + getModuleMetadata(WordpressModule, MODULE_METADATA.CONTROLLERS), + ).toEqual( + expect.arrayContaining([ + WordpressAuthController, + WordpressArticleController, + WordpressTagController, + WordpressCategoryController, + WordpressThemeController, + ]), + ); + expect( + getModuleMetadata(WordpressModule, MODULE_METADATA.PROVIDERS), + ).toEqual(expect.arrayContaining([WordpressService])); + expect(WORDPRESS_MIRROR_CONTROLLERS).toEqual( + expect.arrayContaining([ + WordpressAuthController, + WordpressArticleController, + WordpressTagController, + WordpressCategoryController, + WordpressThemeController, + ]), + ); + expect(WORDPRESS_MIRROR_PROVIDERS).toEqual( + expect.arrayContaining([WordpressService]), + ); + }); + + it('matches the real Batch 3 WordPress mirror SQL schema and sync job contract', () => { + expect(WORDPRESS_MIRROR_DOMAIN_CONTRACT.tables).toEqual([ + 'wordpress_site', + 'wordpress_auth_session', + 'wordpress_remote_post', + 'wordpress_remote_term', + 'wordpress_sync_job', + 'wordpress_sync_mapping', + ]); + for (const table of WORDPRESS_MIRROR_DOMAIN_CONTRACT.tables) { + expect(schema.hasTable(table)).toBe(true); + } + + schema.expectTableColumns('wordpress_site', [ + 'id', + 'site_key', + 'base_url', + 'status', + ]); + schema.expectTableColumns('wordpress_auth_session', [ + 'id', + 'site_id', + 'status', + 'expires_at', + 'safe_summary', + ]); + schema.expectTableColumns('wordpress_remote_post', [ + 'id', + 'site_id', + 'remote_id', + 'slug', + 'status', + 'raw_payload', + ]); + schema.expectTableColumns('wordpress_remote_term', [ + 'id', + 'site_id', + 'remote_id', + 'taxonomy_key', + 'slug', + 'raw_payload', + ]); + schema.expectTableColumns('wordpress_sync_job', [ + 'id', + 'site_id', + 'job_type', + 'status', + 'started_at', + 'finished_at', + 'summary_json', + ]); + schema.expectTableColumns('wordpress_sync_mapping', [ + 'id', + 'site_id', + 'remote_type', + 'remote_id', + 'local_type', + 'local_id', + ]); + + expect(WORDPRESS_MIRROR_DOMAIN_CONTRACT.remotePostMapping).toEqual({ + remoteTable: 'wordpress_remote_post', + mappingTable: 'wordpress_sync_mapping', + remoteType: 'post', + localType: 'blog_post', + remoteKeys: ['site_id', 'remote_id'], + localKey: 'local_id', + }); + expect(WORDPRESS_MIRROR_DOMAIN_CONTRACT.remoteTermMapping).toEqual({ + remoteTable: 'wordpress_remote_term', + mappingTable: 'wordpress_sync_mapping', + remoteType: 'term', + localType: 'blog_term', + remoteKeys: ['site_id', 'taxonomy_key', 'remote_id'], + localKey: 'local_id', + }); + expect(WORDPRESS_MIRROR_DOMAIN_CONTRACT.syncJob).toEqual({ + table: 'wordpress_sync_job', + jobTypeField: 'job_type', + statusField: 'status', + startedAtField: 'started_at', + finishedAtField: 'finished_at', + summaryField: 'summary_json', + }); + }); +});