diff --git a/test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts b/test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts index 15af841..e29c7a1 100644 --- a/test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts @@ -1,8 +1,204 @@ +import { HttpStatus } from '@nestjs/common'; import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service'; import { SystemMessageContractError } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types'; import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity'; import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity'; +const ACCOUNT = { + id: '2041700000000000000', + selfId: '10000000000000001', +}; +const SOURCE_KEY = 'network.stun.mapping-port-changed'; + +type BindingFixtureOptions = { + bindingSaveError?: unknown; + bindings?: any[]; + subscriptionGateError?: Error; + targetSaveError?: unknown; + targets?: any[]; + templateGateError?: Error; +}; + +/** Builds a complete persisted binding row while keeping every Snowflake identity textual. */ +function bindingRow(overrides: Record = {}): any { + return { + accountId: ACCOUNT.id, + activeKey: `${ACCOUNT.id}:2041700000000000001`, + createTime: '2026-07-24 00:00:00', + enabled: true, + id: '2041700000000000003', + isDeleted: false, + selfId: ACCOUNT.selfId, + subscriptionId: '2041700000000000001', + templateId: '2041700000000000002', + updateTime: '2026-07-24 00:00:00', + ...overrides, + }; +} + +/** Builds a target row with the natural key state needed by transaction fakes. */ +function targetRow(overrides: Record = {}): any { + const bindingId = String(overrides.bindingId ?? '2041700000000000003'); + const targetId = String(overrides.targetId ?? '20000000000000001'); + const targetType = String(overrides.targetType ?? 'group'); + return { + activeKey: `${bindingId}:${targetType}:${targetId}`, + bindingId, + createTime: '2026-07-24 00:00:00', + enabled: true, + id: 'target-1', + isDeleted: false, + targetId, + targetName: null, + targetType, + updateTime: '2026-07-24 00:00:00', + ...overrides, + }; +} + +/** + * Creates deterministic repository and dependency-gate fakes for public binding methods. + * @param options - Initial records or injected persistence/gate failures for one scenario. + * @returns The service, its stores, and ordered transaction events for assertions. + */ +function setupBindingFixture(options: BindingFixtureOptions = {}) { + const bindings = options.bindings ?? []; + const targets = options.targets ?? []; + const events: string[] = []; + let nextBindingId = 4; + let nextTargetId = 1; + const subscription = { + enabled: true, + id: '2041700000000000001', + isDeleted: false, + name: '订阅', + sourceConfig: {}, + sourceKey: SOURCE_KEY, + }; + const template = { + content: '正文', + enabled: true, + id: '2041700000000000002', + isDeleted: false, + name: '模板', + sourceKey: SOURCE_KEY, + }; + const bindingStore = { + create: jest.fn((value: any) => + bindingRow({ id: `204170000000000000${nextBindingId++}`, ...value }), + ), + find: jest.fn(async () => bindings), + findOne: jest.fn(async (query: any) => { + const where = query.where as Record; + const matching = bindings.filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ); + if (query.order?.updateTime === 'DESC') { + return ( + matching.sort((left, right) => { + const time = String(right.updateTime).localeCompare( + String(left.updateTime), + ); + return time || String(right.id).localeCompare(String(left.id)); + })[0] ?? null + ); + } + return matching[0] ?? null; + }), + save: jest.fn(async (row: any) => { + if (options.bindingSaveError) throw options.bindingSaveError; + const existing = bindings.find((candidate) => candidate.id === row.id); + if (existing) Object.assign(existing, row); + else bindings.push(row); + events.push('binding:save'); + return row; + }), + }; + const targetStore = { + create: jest.fn((value: any) => + targetRow({ + createTime: `2026-07-25 00:00:0${nextTargetId}`, + id: `target-new-${nextTargetId++}`, + ...value, + }), + ), + find: jest.fn(async (query: any) => { + const where = query.where as Record; + return targets + .filter((row) => + Object.entries(where).every(([key, value]) => row[key] === value), + ) + .sort((left, right) => { + const time = String(left.createTime).localeCompare( + String(right.createTime), + ); + return time || String(left.id).localeCompare(String(right.id)); + }); + }), + save: jest.fn(async (rows: any[]) => { + if (options.targetSaveError) throw options.targetSaveError; + rows.forEach((row) => { + const existing = targets.find((candidate) => candidate.id === row.id); + if (existing) Object.assign(existing, row); + else targets.push(row); + }); + events.push('targets:save'); + return rows; + }), + }; + const manager = { + getRepository: jest.fn((entity) => { + if (entity === QqbotMessagePublishBinding) return bindingStore; + if (entity === QqbotMessagePublishTarget) return targetStore; + throw new Error('unexpected repository'); + }), + }; + const transaction = jest.fn(async (callback: any) => { + events.push('transaction:start'); + const result = await callback(manager); + events.push('transaction:commit'); + return result; + }); + Object.assign(bindingStore, { manager: { transaction } }); + const subscriptionGate = jest.fn(async (...args: any[]) => { + events.push(`subscription:${args[2]}`); + if (options.subscriptionGateError) throw options.subscriptionGateError; + return subscription; + }); + const templateGate = jest.fn(async (...args: any[]) => { + events.push(`template:${args[3]}`); + if (options.templateGateError) throw options.templateGateError; + return template; + }); + return { + bindings, + bindingStore, + events, + manager, + service: new QqbotAccountMessagePushService( + bindingStore as never, + targetStore as never, + { findOne: async () => subscription } as never, + { findOne: async () => template } as never, + { findBySelfId: async () => ACCOUNT } as never, + { requireAvailableForBinding: subscriptionGate } as never, + { requireAvailableForBinding: templateGate } as never, + { + get: () => ({ + definition: { displayName: 'STUN 映射', variables: [] }, + inspectSubscription: async () => ({ valid: true }), + }), + } as never, + { validate: jest.fn() } as never, + ), + subscriptionGate, + targetStore, + targets, + templateGate, + transaction, + }; +} + describe('QqbotAccountMessagePushService', () => { it('normalizes targets without number coercion and rejects invalid manual values', () => { const service = new QqbotAccountMessagePushService( @@ -353,4 +549,428 @@ describe('QqbotAccountMessagePushService', () => { }), ]); }); + + it('revives the newest matching deleted binding under its revalidated write lock', async () => { + const older = bindingRow({ + activeKey: null, + id: '2041700000000000004', + isDeleted: true, + templateId: 'old-template', + updateTime: '2026-07-23 00:00:00', + }); + const newest = bindingRow({ + activeKey: null, + enabled: false, + id: '2041700000000000005', + isDeleted: true, + templateId: 'old-template', + updateTime: '2026-07-24 00:00:00', + }); + const fixture = setupBindingFixture({ bindings: [older, newest] }); + + await expect( + fixture.service.createBinding(ACCOUNT.selfId, { + enabled: true, + subscriptionId: '2041700000000000001', + templateId: '2041700000000000002', + targets: [ + { + targetId: '20000000000000001', + targetName: '新群名', + targetType: 'group', + }, + ], + }), + ).resolves.toMatchObject({ + id: '2041700000000000005', + targets: [ + { + id: 'target-new-1', + targetId: '20000000000000001', + targetName: '新群名', + targetType: 'group', + }, + ], + }); + + expect(fixture.bindingStore.findOne).toHaveBeenNthCalledWith(1, { + where: { + activeKey: `${ACCOUNT.id}:2041700000000000001`, + isDeleted: false, + }, + }); + expect(fixture.bindingStore.findOne).toHaveBeenCalledWith({ + lock: { mode: 'pessimistic_write' }, + where: { + accountId: ACCOUNT.id, + id: '2041700000000000005', + isDeleted: true, + selfId: ACCOUNT.selfId, + subscriptionId: '2041700000000000001', + }, + }); + expect(newest).toMatchObject({ + activeKey: `${ACCOUNT.id}:2041700000000000001`, + enabled: true, + id: '2041700000000000005', + isDeleted: false, + templateId: '2041700000000000002', + }); + expect(fixture.bindingStore.create).not.toHaveBeenCalled(); + expect(fixture.events).toEqual([ + 'transaction:start', + 'subscription:true', + 'template:true', + 'binding:save', + 'targets:save', + 'transaction:commit', + ]); + }); + + it('synchronizes retained, revived, inserted, and removed targets before commit', async () => { + const binding = bindingRow(); + const retained = targetRow({ + createTime: '2026-07-24 00:00:01', + id: 'target-retained', + targetId: '20000000000000001', + targetName: '旧名称', + }); + const revived = targetRow({ + activeKey: null, + createTime: '2026-07-24 00:00:02', + enabled: false, + id: 'target-revived', + isDeleted: true, + targetId: '30000000000000001', + targetName: '历史名称', + targetType: 'private', + }); + const removed = targetRow({ + createTime: '2026-07-24 00:00:03', + id: 'target-removed', + targetId: '40000000000000001', + targetName: '待移除', + }); + const fixture = setupBindingFixture({ + bindings: [binding], + targets: [retained, revived, removed], + }); + + await expect( + fixture.service.updateBinding(ACCOUNT.selfId, binding.id, { + enabled: true, + subscriptionId: binding.subscriptionId, + templateId: binding.templateId, + targets: [ + { + targetId: retained.targetId, + targetName: '保留后的名称', + targetType: 'group', + }, + { + targetId: revived.targetId, + targetName: '复活后的名称', + targetType: 'private', + }, + { + targetId: '50000000000000001', + targetName: '新增私聊', + targetType: 'private', + }, + ], + }), + ).resolves.toMatchObject({ + id: binding.id, + targets: [ + { + id: 'target-retained', + targetId: '20000000000000001', + targetName: '保留后的名称', + targetType: 'group', + }, + { + id: 'target-revived', + targetId: '30000000000000001', + targetName: '复活后的名称', + targetType: 'private', + }, + { + id: 'target-new-1', + targetId: '50000000000000001', + targetName: '新增私聊', + targetType: 'private', + }, + ], + }); + + expect(retained).toMatchObject({ + activeKey: `${binding.id}:group:20000000000000001`, + enabled: true, + isDeleted: false, + targetName: '保留后的名称', + }); + expect(revived).toMatchObject({ + activeKey: `${binding.id}:private:30000000000000001`, + enabled: true, + id: 'target-revived', + isDeleted: false, + targetName: '复活后的名称', + }); + expect(removed).toMatchObject({ + activeKey: null, + enabled: false, + isDeleted: true, + targetId: '40000000000000001', + }); + expect(fixture.targetStore.find).toHaveBeenCalledWith({ + lock: { mode: 'pessimistic_write' }, + order: { createTime: 'ASC', id: 'ASC' }, + where: { bindingId: binding.id }, + }); + expect(fixture.events).toEqual([ + 'transaction:start', + 'subscription:true', + 'template:true', + 'binding:save', + 'targets:save', + 'transaction:commit', + ]); + }); + + it('retires only the requested account binding and all of its active targets atomically', async () => { + const binding = bindingRow(); + const ownTarget = targetRow({ id: 'target-own' }); + const historicalTarget = targetRow({ + activeKey: null, + enabled: false, + id: 'target-historical', + isDeleted: true, + targetId: '30000000000000001', + }); + const foreignTarget = targetRow({ + bindingId: 'foreign-binding', + id: 'target-foreign', + targetId: '40000000000000001', + }); + const fixture = setupBindingFixture({ + bindings: [binding], + targets: [ownTarget, historicalTarget, foreignTarget], + }); + + await expect( + fixture.service.removeBinding(ACCOUNT.selfId, binding.id), + ).resolves.toBe(true); + + expect(fixture.bindingStore.findOne).toHaveBeenCalledTimes(1); + expect(fixture.bindingStore.findOne).toHaveBeenCalledWith({ + lock: { mode: 'pessimistic_write' }, + where: { accountId: ACCOUNT.id, id: binding.id, isDeleted: false }, + }); + expect(fixture.targetStore.find).toHaveBeenCalledWith({ + lock: { mode: 'pessimistic_write' }, + where: { bindingId: binding.id, isDeleted: false }, + }); + expect(fixture.targetStore.save).toHaveBeenCalledWith([ownTarget]); + expect(binding).toMatchObject({ + activeKey: null, + enabled: false, + isDeleted: true, + }); + expect(ownTarget).toMatchObject({ + activeKey: null, + enabled: false, + isDeleted: true, + }); + expect(historicalTarget).toMatchObject({ + activeKey: null, + enabled: false, + isDeleted: true, + }); + expect(foreignTarget).toMatchObject({ + activeKey: 'foreign-binding:group:40000000000000001', + enabled: true, + isDeleted: false, + }); + expect(fixture.events).toEqual([ + 'transaction:start', + 'targets:save', + 'binding:save', + 'transaction:commit', + ]); + }); + + it('rejects deleted and cross-account removals with the approved binding_disabled code', async () => { + const deleted = setupBindingFixture({ + bindings: [bindingRow({ isDeleted: true })], + }); + const crossAccount = setupBindingFixture({ + bindings: [bindingRow({ accountId: 'other-account' })], + }); + + await expect( + deleted.service.removeBinding(ACCOUNT.selfId, '2041700000000000003'), + ).rejects.toMatchObject({ code: 'binding_disabled' }); + await expect( + crossAccount.service.removeBinding(ACCOUNT.selfId, '2041700000000000003'), + ).rejects.toMatchObject({ code: 'binding_disabled' }); + }); + + it('uses subscription then template gates for enabled create, update, and toggle persistence', async () => { + const create = setupBindingFixture(); + const updateBinding = bindingRow({ enabled: false }); + const update = setupBindingFixture({ bindings: [updateBinding] }); + const toggleBinding = bindingRow({ enabled: false }); + const toggle = setupBindingFixture({ bindings: [toggleBinding] }); + const input = { + enabled: true, + subscriptionId: '2041700000000000001', + templateId: '2041700000000000002', + targets: [ + { targetId: '20000000000000001', targetType: 'group' as const }, + ], + }; + + await expect( + create.service.createBinding(ACCOUNT.selfId, input), + ).resolves.toMatchObject({ enabled: true }); + await expect( + update.service.updateBinding(ACCOUNT.selfId, updateBinding.id, input), + ).resolves.toMatchObject({ enabled: true }); + await expect( + toggle.service.setBindingEnabled(ACCOUNT.selfId, toggleBinding.id, true), + ).resolves.toMatchObject({ enabled: true }); + + for (const fixture of [create, update, toggle]) { + expect(fixture.subscriptionGate).toHaveBeenCalledWith( + fixture.manager, + '2041700000000000001', + true, + ); + expect(fixture.templateGate).toHaveBeenCalledWith( + fixture.manager, + '2041700000000000002', + SOURCE_KEY, + true, + ); + expect(fixture.events.indexOf('subscription:true')).toBeLessThan( + fixture.events.indexOf('template:true'), + ); + } + expect(updateBinding.enabled).toBe(true); + expect(toggleBinding.enabled).toBe(true); + }); + + it('keeps source-compatible gates for disabled writes and persists nothing after gate rejection', async () => { + const disabledCreate = setupBindingFixture(); + const disabledUpdateBinding = bindingRow(); + const disabledUpdate = setupBindingFixture({ + bindings: [disabledUpdateBinding], + }); + const disabledToggleBinding = bindingRow(); + const disabledToggle = setupBindingFixture({ + bindings: [disabledToggleBinding], + }); + const sourceMismatch = setupBindingFixture({ + templateGateError: new SystemMessageContractError('template_invalid'), + }); + const disabledSubscription = setupBindingFixture({ + subscriptionGateError: new SystemMessageContractError( + 'subscription_disabled', + ), + }); + const disabledInput = { + enabled: false, + subscriptionId: '2041700000000000001', + templateId: '2041700000000000002', + targets: [ + { targetId: '20000000000000001', targetType: 'group' as const }, + ], + }; + + await expect( + disabledCreate.service.createBinding(ACCOUNT.selfId, disabledInput), + ).resolves.toMatchObject({ enabled: false }); + await expect( + disabledUpdate.service.updateBinding( + ACCOUNT.selfId, + disabledUpdateBinding.id, + disabledInput, + ), + ).resolves.toMatchObject({ enabled: false }); + await expect( + disabledToggle.service.setBindingEnabled( + ACCOUNT.selfId, + disabledToggleBinding.id, + false, + ), + ).resolves.toMatchObject({ enabled: false }); + for (const fixture of [disabledCreate, disabledUpdate, disabledToggle]) { + expect(fixture.subscriptionGate).toHaveBeenCalledWith( + fixture.manager, + disabledInput.subscriptionId, + false, + ); + expect(fixture.templateGate).toHaveBeenCalledWith( + fixture.manager, + disabledInput.templateId, + SOURCE_KEY, + false, + ); + } + await expect( + sourceMismatch.service.createBinding(ACCOUNT.selfId, disabledInput), + ).rejects.toMatchObject({ code: 'template_invalid' }); + await expect( + disabledSubscription.service.createBinding(ACCOUNT.selfId, { + ...disabledInput, + enabled: true, + }), + ).rejects.toMatchObject({ code: 'subscription_disabled' }); + for (const fixture of [sourceMismatch, disabledSubscription]) { + expect(fixture.bindingStore.save).not.toHaveBeenCalled(); + expect(fixture.targetStore.save).not.toHaveBeenCalled(); + } + }); + + it('maps active-key conflicts and duplicate persistence races to Vben HTTP 409', async () => { + const input = { + enabled: true, + subscriptionId: '2041700000000000001', + templateId: '2041700000000000002', + targets: [ + { targetId: '20000000000000001', targetType: 'group' as const }, + ], + }; + const active = setupBindingFixture({ bindings: [bindingRow()] }); + const bindingRace = setupBindingFixture({ + bindingSaveError: { code: 'ER_DUP_ENTRY' }, + }); + const targetRace = setupBindingFixture({ + targetSaveError: { errno: 1062 }, + }); + + await expect( + active.service.createBinding(ACCOUNT.selfId, input), + ).rejects.toMatchObject({ status: HttpStatus.CONFLICT }); + await expect( + bindingRace.service.createBinding(ACCOUNT.selfId, input), + ).rejects.toMatchObject({ status: HttpStatus.CONFLICT }); + await expect( + targetRace.service.createBinding(ACCOUNT.selfId, input), + ).rejects.toMatchObject({ status: HttpStatus.CONFLICT }); + }); + + it('rethrows non-duplicate persistence failures unchanged', async () => { + const failure = new Error('database unavailable'); + const fixture = setupBindingFixture({ bindingSaveError: failure }); + + await expect( + fixture.service.createBinding(ACCOUNT.selfId, { + enabled: true, + subscriptionId: '2041700000000000001', + templateId: '2041700000000000002', + targets: [{ targetId: '20000000000000001', targetType: 'group' }], + }), + ).rejects.toBe(failure); + }); });