import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import sharp from 'sharp'; import { buildApp } from '../dist/app.js'; import { signAccessToken } from '../dist/auth/jwt.js'; import { MediaStorage, MediaValidationError } from '../dist/content/media-storage.js'; import { ContentError, ContentRepository } from '../dist/content/content-repository.js'; const root = await mkdtemp(join(tmpdir(), 'qipai-media-')); try { const png = await sharp({ create: { width: 2400, height: 1200, channels: 3, background: '#336699' } }).png().toBuffer(); const storage = new MediaStorage(root); const image = await storage.storeImage({ tenantId: '7', storeId: '11', originalName: 'banner.png', contentType: 'image/png', body: png }); assert.equal(image.mimeType, 'image/webp'); assert.ok(image.width <= 1920); assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/); const storedPath = join(root, ...image.storagePath.split('/')); const storedBytes = await readFile(storedPath); assert.ok(storedBytes.length > 0); const storedMetadata = await sharp(storedBytes).metadata(); assert.equal(storedMetadata.exif, undefined); assert.equal(storedMetadata.icc, undefined); await assert.rejects( () => storage.storeImage({ tenantId: '7', originalName: 'spoofed.jpg', contentType: 'image/jpeg', body: png }), (error) => error instanceof MediaValidationError && error.code === 'IMAGE_DECODE_FAILED' ); await assert.rejects( () => storage.storeImage({ tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain', body: Buffer.from('not an image') }), (error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID' ); await storage.deleteImage(image.storagePath); await assert.rejects(() => readFile(storedPath), (error) => error.code === 'ENOENT'); await assert.rejects( () => storage.deleteImage('../outside.webp'), (error) => error instanceof MediaValidationError && error.code === 'IMAGE_PATH_INVALID' ); } finally { await rm(root, { recursive: true, force: true }); } const storeActor = { tenantId: '7', userId: '21', access: { roles: ['STORE_ADMIN'], capabilities: ['store.operation.write'], storeIds: ['11'] }, traceId: 'trace', ip: '127.0.0.1', userAgent: 'test' }; const repository = new ContentRepository({ async execute() { return [[], []]; } }); await assert.rejects( () => repository.saveAdvertisement(storeActor, { scopeType: 'PLATFORM', title: 'x', imageAssetId: '1', targetType: 'NONE', targetValue: '', status: 'DRAFT', sortOrder: 0 }), (error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN' ); await assert.rejects( () => repository.registerAsset(storeActor, undefined, { storagePath: 'asset.webp', publicUrl: '/asset.webp', mimeType: 'image/webp', byteSize: 100, width: 10, height: 10, checksumSha256: '0'.repeat(64) }), (error) => error instanceof ContentError && error.code === 'GLOBAL_ASSET_FORBIDDEN' ); const secret = 'test-only-content-management-secret-32'; const token = signAccessToken({ sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900); let listedAssetStoreId; let listedDecorationStoreId; let updatedAdvertisement; const app = await buildApp({ content: { jwtSecret: secret, authRepository: { async validateSession() { return { id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } }, accessControl: { async getAccessProfile() { return storeActor.access; } }, mediaStorage: { async storeImage() { throw new Error('not used'); } }, repository: { async listAssets(_actor, storeId) { listedAssetStoreId = storeId; return [{ id: '31' }]; }, async listDecorations(_actor, storeId) { listedDecorationStoreId = storeId; return [{ id: '41' }]; }, async updateAdvertisement(_actor, id, input) { updatedAdvertisement = { id, input }; return { advertisementId: id }; }, async listAdvertisements() { return []; }, async registerAsset() {}, async saveDecoration() {}, async publishDecoration() {}, async saveAdvertisement() {} } } }); const authorization = { authorization: `Bearer ${token}` }; const assets = await app.inject({ method: 'GET', url: '/admin-api/media/images?storeId=11', headers: authorization }); assert.equal(assets.statusCode, 200); assert.equal(listedAssetStoreId, '11'); const decorations = await app.inject({ method: 'GET', url: '/admin-api/decorations?storeId=11', headers: authorization }); assert.equal(decorations.statusCode, 200); assert.equal(listedDecorationStoreId, '11'); const updated = await app.inject({ method: 'PUT', url: '/admin-api/advertisements/51', headers: authorization, payload: { scopeType: 'STORE', storeId: '11', title: '门店轮播', imageAssetId: '31', targetType: 'PAGE', targetValue: '/pages/booking/index', status: 'ACTIVE', sortOrder: 10 } }); assert.equal(updated.statusCode, 200); assert.equal(updatedAdvertisement.id, '51'); assert.equal(updatedAdvertisement.input.storeId, '11'); const missingStore = await app.inject({ method: 'GET', url: '/admin-api/decorations', headers: authorization }); assert.equal(missingStore.statusCode, 400); await app.close(); console.log('PASS: content media, decoration and advertisement management contracts are scoped and editable.');