import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; import { AdminFieldSchema } from '../admin.models'; import { CloudFunctionsService } from '../cloud-functions.service'; import { AdminResourceComponent } from './admin-resource.component'; describe('AdminResourceComponent', () => { let fixture: ComponentFixture; let component: AdminResourceComponent; const functions = { admin: jasmine.createSpy('admin') }; const objectField: AdminFieldSchema = { name: 'payload', type: 'Object', writable: true }; beforeEach(async () => { functions.admin.calls.reset(); await TestBed.configureTestingModule({ imports: [AdminResourceComponent], providers: [ { provide: ActivatedRoute, useValue: { paramMap: of({ get: () => 'CommonModel' }) } }, { provide: CloudFunctionsService, useValue: functions }, ], }).compileComponents(); fixture = TestBed.createComponent(AdminResourceComponent); component = fixture.componentInstance; }); it('keeps invalid JSON text visible and blocks saving it as a string', async () => { component.setField(objectField, '{invalid'); expect(component.inputValue(objectField)).toBe('{invalid'); expect(component.fieldErrors()['payload']).toContain('JSON 对象'); await component.save(); expect(functions.admin).not.toHaveBeenCalled(); expect(component.error()).toContain('payload'); }); it('accepts a valid JSON object as structured data', () => { component.setField(objectField, '{"enabled":true}'); expect(component.fieldErrors()['payload']).toBeUndefined(); expect(component.draft()['payload']).toEqual({ enabled: true }); }); it('keeps preferred columns first and rounds page counts upward', () => { component.schema.set({ className: 'CommonModel', label: '内容', writable: true, supportsSoftDelete: true, fields: [ { name: 'misc', type: 'String', writable: true }, { name: 'title', type: 'String', writable: true }, { name: 'objectId', type: 'String', writable: false }, ], }); component.page.set({ className: 'CommonModel', page: 1, pageSize: 20, total: 21, results: [] }); expect(component.columns().map((field) => field.name)).toEqual(['objectId', 'title', 'misc']); expect(component.pageCount()).toBe(2); }); it('uses the dedicated cloud operation to reset an existing user password', async () => { functions.admin.and.resolveTo({ objectId: 'user-object' }); component.className.set('_User'); component.draftId.set('user-object'); component.newPassword.set('SafePass123'); component.confirmPassword.set('SafePass123'); await component.resetPassword(); expect(functions.admin).toHaveBeenCalledWith('resetPassword', { className: '_User', objectId: 'user-object', newPassword: 'SafePass123' }); expect(component.passwordMessage()).toContain('已安全重置'); }); it('uses the dedicated ordinary-account operation without generic role fields', async () => { component.className.set('_User'); component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] }); component.createUsername.set('student-1001'); component.createPassword.set('SafePass123'); component.createConfirmPassword.set('SafePass123'); component.createDisplayName.set('新学员'); component.createMobile.set('13800138000'); functions.admin.and.callFake(async (operation: string) => operation === 'createUser' ? { objectId: 'new-user', userId: 1001, groupId: 1 } : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] }); await component.createUser(); expect(functions.admin.calls.first().args).toEqual(['createUser', { username: 'student-1001', password: 'SafePass123', displayName: '新学员', mobile: '13800138000', }]); expect(component.editorOpen()).toBeFalse(); }); it('rejects mismatched account passwords before calling the cloud function', async () => { component.className.set('_User'); component.createUsername.set('student-1002'); component.createPassword.set('SafePass123'); component.createConfirmPassword.set('Different123'); await component.createUser(); expect(functions.admin).not.toHaveBeenCalled(); expect(component.createUserError()).toContain('不一致'); }); it('uses the protected batch operation to lock a user and reports revoked sessions', async () => { spyOn(window, 'confirm').and.returnValue(true); component.className.set('_User'); component.draftId.set('member-object'); component.draft.set({ objectId: 'member-object', isDisabled: false }); component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] }); functions.admin.and.callFake(async (operation: string) => operation === 'userBatch' ? { action: 'lock', updated: 1, revokedSessions: 2, results: [{ objectId: 'member-object', isDisabled: true }] } : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] }); await component.setUserDisabled(true); expect(functions.admin.calls.first().args).toEqual(['userBatch', { className: '_User', action: 'lock', objectIds: ['member-object'], reason: '' }]); expect(component.draft()['isDisabled']).toBeTrue(); expect(component.userActionMessage()).toContain('2 个会话'); }); it('moves a user through the dedicated group synchronization operation', async () => { component.className.set('_User'); component.draftId.set('member-object'); component.draft.set({ objectId: 'member-object', legacyGroupId: 1 }); component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] }); component.selectedUserGroupId.set(3); functions.admin.and.callFake(async (operation: string) => operation === 'userBatch' ? { action: 'move', updated: 1, revokedSessions: 0, results: [{ objectId: 'member-object', legacyGroupId: 3, legacyUserData: { GroupID: 3 } }] } : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] }); await component.moveUserGroup(); expect(functions.admin.calls.first().args).toEqual(['userBatch', { className: '_User', action: 'move', objectIds: ['member-object'], groupId: 3 }]); expect(component.draft()['legacyGroupId']).toBe(3); }); it('routes group creation through the atomic group operation', async () => { component.className.set('Group'); component.schema.set({ className: 'Group', label: '用户组', writable: true, supportsSoftDelete: false, fields: [] }); component.draft.set({ groupName: '普通会员', parentGroupId: 0, regSelect: true }); functions.admin.and.callFake(async (operation: string) => operation === 'saveGroup' ? { objectId: 'group-object', groupId: 1, groupName: '普通会员' } : { className: 'Group', page: 1, pageSize: 20, total: 1, results: [] }); await component.save(); expect(functions.admin.calls.first().args).toEqual(['saveGroup', { className: 'Group', objectId: undefined, fields: { groupName: '普通会员', parentGroupId: 0, regSelect: true }, }]); }); it('routes node creation through the structural cloud operation', async () => { component.className.set('Node'); component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] }); component.draft.set({ nodeName: '新栏目', nodeDir: 'new-node', parentId: 999, nodeId: 999 }); component.targetNodeParentId.set(16); functions.admin.and.callFake(async (operation: string) => operation === 'saveNode' ? { objectId: 'node-object', nodeId: 401, nodeName: '新栏目', parentId: 16 } : { className: 'Node', page: 1, pageSize: 20, total: 1, results: [] }); await component.save(); expect(functions.admin.calls.first().args).toEqual(['saveNode', { className: 'Node', objectId: undefined, fields: { nodeName: '新栏目', nodeDir: 'new-node', parentId: 999, nodeId: 999 }, parentId: 16, }]); }); it('moves a node through the cycle-protected batch operation', async () => { component.className.set('Node'); component.draftId.set('node-object'); component.draft.set({ objectId: 'node-object', nodeId: 40, parentId: 16, zstatus: 99 }); component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] }); component.targetNodeParentId.set(29); functions.admin.and.callFake(async (operation: string) => operation === 'nodeBatch' ? { action: 'move', updated: 1, results: [{ objectId: 'node-object', nodeId: 40, parentId: 29, zstatus: 99 }] } : { className: 'Node', page: 1, pageSize: 20, total: 1, results: [] }); await component.applyNodeAction('move'); expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'move', objectIds: ['node-object'], parentId: 29 }]); expect(component.draft()['parentId']).toBe(29); expect(component.nodeActionMessage()).toContain('29'); }); it('routes special creation through the structural cloud operation', async () => { component.className.set('Special'); component.schema.set({ className: 'Special', label: '专题', writable: true, supportsSoftDelete: false, fields: [] }); component.draft.set({ specName: '新专题', specDir: 'new-special', specId: 999, pid: 999 }); component.targetSpecialParentId.set(1); functions.admin.and.callFake(async (operation: string) => operation === 'saveSpecial' ? { objectId: 'special-object', specId: 10, specName: '新专题', pid: 1 } : { className: 'Special', page: 1, pageSize: 20, total: 1, results: [] }); await component.save(); expect(functions.admin.calls.first().args).toEqual(['saveSpecial', { className: 'Special', objectId: undefined, fields: { specName: '新专题', specDir: 'new-special', specId: 999, pid: 999 }, pid: 1, }]); }); it('moves a special through the dedicated batch operation', async () => { component.className.set('Special'); component.draftId.set('special-object'); component.draft.set({ objectId: 'special-object', specId: 10, pid: 1 }); component.schema.set({ className: 'Special', label: '专题', writable: true, supportsSoftDelete: false, fields: [] }); component.targetSpecialParentId.set(0); functions.admin.and.callFake(async (operation: string) => operation === 'specialBatch' ? { action: 'move', updated: 1, results: [{ objectId: 'special-object', specId: 10, pid: 0 }] } : { className: 'Special', page: 1, pageSize: 20, total: 1, results: [] }); await component.moveSpecial(); expect(functions.admin.calls.first().args).toEqual(['specialBatch', { className: 'Special', action: 'move', objectIds: ['special-object'], pid: 0 }]); expect(component.specialActionMessage()).toContain('0'); }); it('updates existing model metadata through the protected operation', async () => { component.className.set('Model'); component.draftId.set('model-object'); component.schema.set({ className: 'Model', label: '内容模型', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); component.draft.set({ objectId: 'model-object', modelId: 2, tableName: 'ZL_C_Article', modelName: '文章模型', description: '安全说明' }); functions.admin.and.callFake(async (operation: string) => operation === 'saveModelMetadata' ? { objectId: 'model-object', modelId: 2, modelName: '文章模型', description: '安全说明' } : { className: 'Model', page: 1, pageSize: 20, total: 1, results: [] }); await component.save(); expect(functions.admin.calls.first().args).toEqual(['saveModelMetadata', { className: 'Model', objectId: 'model-object', fields: { objectId: 'model-object', modelId: 2, tableName: 'ZL_C_Article', modelName: '文章模型', description: '安全说明' }, }]); }); it('updates existing model-field presentation metadata without structural writes', async () => { component.className.set('ModelField'); component.draftId.set('field-object'); component.schema.set({ className: 'ModelField', label: '模型字段', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); component.draft.set({ objectId: 'field-object', fieldId: 3, modelId: 2, fieldName: 'author', fieldType: 'TextType', fieldAlias: '作者' }); functions.admin.and.callFake(async (operation: string) => operation === 'saveModelFieldMetadata' ? { objectId: 'field-object', fieldId: 3, fieldAlias: '作者' } : { className: 'ModelField', page: 1, pageSize: 20, total: 1, results: [] }); await component.save(); expect(functions.admin.calls.first().args).toEqual(['saveModelFieldMetadata', { className: 'ModelField', objectId: 'field-object', fields: { objectId: 'field-object', fieldId: 3, modelId: 2, fieldName: 'author', fieldType: 'TextType', fieldAlias: '作者' }, }]); }); it('updates an existing guestbook entry through the dedicated editor', async () => { component.className.set('Guestbook'); component.draftId.set('guest-object'); component.schema.set({ className: 'Guestbook', label: '留言', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); component.draft.set({ objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' }); functions.admin.and.callFake(async (operation: string) => operation === 'saveGuestbook' ? { objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' } : { className: 'Guestbook', page: 1, pageSize: 20, total: 1, results: [] }); await component.save(); expect(functions.admin.calls.first().args).toEqual(['saveGuestbook', { className: 'Guestbook', objectId: 'guest-object', fields: { objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' }, }]); }); it('audits a guestbook entry through the workflow operation', async () => { component.className.set('Guestbook'); component.draftId.set('guest-object'); component.draft.set({ objectId: 'guest-object', gid: 10, status: 0 }); component.schema.set({ className: 'Guestbook', label: '留言', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); functions.admin.and.callFake(async (operation: string) => operation === 'guestbookBatch' ? { action: 'audit', updated: 1, results: [{ objectId: 'guest-object', gid: 10, status: 99 }] } : { className: 'Guestbook', page: 1, pageSize: 20, total: 1, results: [] }); await component.applyGuestbookAction('audit'); expect(functions.admin.calls.first().args).toEqual(['guestbookBatch', { className: 'Guestbook', action: 'audit', objectIds: ['guest-object'] }]); expect(component.selectedGuestbookStatus()).toBe(99); }); it('publishes an admin reply through the parent-safe cloud operation', async () => { component.className.set('Guestbook'); component.draftId.set('guest-object'); component.guestbookReplyTitle.set('回复标题'); component.guestbookReplyContent.set('回复正文'); functions.admin.and.resolveTo({ objectId: 'reply-object', gid: 11, parentid: 10, status: 99 }); await component.replyGuestbook(); expect(functions.admin).toHaveBeenCalledWith('guestbookReply', { className: 'Guestbook', parentObjectId: 'guest-object', title: '回复标题', content: '回复正文' }); expect(component.guestbookActionMessage()).toContain('已发布'); }); it('updates content workflow state through the dedicated batch operation', async () => { component.className.set('CommonModel'); component.draftId.set('content-object'); component.draft.set({ objectId: 'content-object', status: 0, nodeId: 29 }); component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] }); component.selectedContentStatus.set(99); functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch' ? { action: 'status', updated: 1, results: [{ objectId: 'content-object', status: 99, nodeId: 29 }] } : { className: 'CommonModel', page: 1, pageSize: 20, total: 1, results: [] }); await component.applyContentAction('status'); expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-object'], status: 99 }]); expect(component.contentStatusValue()).toBe(99); expect(component.contentActionMessage()).toContain('已审核'); }); it('selects and clears all records on the current page', () => { component.className.set('CommonModel'); component.page.set({ className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [{ objectId: 'content-a' }, { objectId: 'content-b' }] }); component.togglePageSelection(true); expect(component.selectedIds()).toEqual(['content-a', 'content-b']); expect(component.allPageSelected()).toBeTrue(); component.toggleSelection('content-a', false); expect(component.selectedIds()).toEqual(['content-b']); expect(component.allPageSelected()).toBeFalse(); }); it('sends all selected content ids through the bulk workflow operation', async () => { component.className.set('CommonModel'); component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] }); component.selectedIds.set(['content-a', 'content-b']); component.selectedContentStatus.set(99); functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch' ? { action: 'status', updated: 2, results: [] } : { className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [] }); await component.applyBulkContentAction('status'); expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-a', 'content-b'], status: 99 }]); expect(component.bulkMessage()).toContain('2 条内容'); expect(component.selectedIds()).toEqual([]); }); it('recovers all selected nodes through the dedicated batch operation', async () => { component.className.set('Node'); component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] }); component.selectedIds.set(['node-a', 'node-b']); functions.admin.and.callFake(async (operation: string) => operation === 'nodeBatch' ? { action: 'recover', updated: 2, results: [] } : { className: 'Node', page: 1, pageSize: 20, total: 2, results: [] }); await component.applyBulkNodeAction('recover'); expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'recover', objectIds: ['node-a', 'node-b'] }]); expect(component.bulkMessage()).toContain('2 个栏目'); }); });