admin-resource.component.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { ComponentFixture, TestBed } from '@angular/core/testing';
  2. import { ActivatedRoute } from '@angular/router';
  3. import { of } from 'rxjs';
  4. import { AdminFieldSchema } from '../admin.models';
  5. import { CloudFunctionsService } from '../cloud-functions.service';
  6. import { AdminResourceComponent } from './admin-resource.component';
  7. describe('AdminResourceComponent', () => {
  8. let fixture: ComponentFixture<AdminResourceComponent>;
  9. let component: AdminResourceComponent;
  10. const functions = { admin: jasmine.createSpy('admin') };
  11. const objectField: AdminFieldSchema = { name: 'payload', type: 'Object', writable: true };
  12. beforeEach(async () => {
  13. functions.admin.calls.reset();
  14. await TestBed.configureTestingModule({
  15. imports: [AdminResourceComponent],
  16. providers: [
  17. { provide: ActivatedRoute, useValue: { paramMap: of({ get: () => 'CommonModel' }) } },
  18. { provide: CloudFunctionsService, useValue: functions },
  19. ],
  20. }).compileComponents();
  21. fixture = TestBed.createComponent(AdminResourceComponent);
  22. component = fixture.componentInstance;
  23. });
  24. it('keeps invalid JSON text visible and blocks saving it as a string', async () => {
  25. component.setField(objectField, '{invalid');
  26. expect(component.inputValue(objectField)).toBe('{invalid');
  27. expect(component.fieldErrors()['payload']).toContain('JSON 对象');
  28. await component.save();
  29. expect(functions.admin).not.toHaveBeenCalled();
  30. expect(component.error()).toContain('payload');
  31. });
  32. it('accepts a valid JSON object as structured data', () => {
  33. component.setField(objectField, '{"enabled":true}');
  34. expect(component.fieldErrors()['payload']).toBeUndefined();
  35. expect(component.draft()['payload']).toEqual({ enabled: true });
  36. });
  37. it('keeps preferred columns first and rounds page counts upward', () => {
  38. component.schema.set({
  39. className: 'CommonModel', label: '内容', writable: true, supportsSoftDelete: true,
  40. fields: [
  41. { name: 'misc', type: 'String', writable: true },
  42. { name: 'title', type: 'String', writable: true },
  43. { name: 'objectId', type: 'String', writable: false },
  44. ],
  45. });
  46. component.page.set({ className: 'CommonModel', page: 1, pageSize: 20, total: 21, results: [] });
  47. expect(component.columns().map((field) => field.name)).toEqual(['objectId', 'title', 'misc']);
  48. expect(component.pageCount()).toBe(2);
  49. });
  50. it('uses the dedicated cloud operation to reset an existing user password', async () => {
  51. functions.admin.and.resolveTo({ objectId: 'user-object' });
  52. component.className.set('_User');
  53. component.draftId.set('user-object');
  54. component.newPassword.set('SafePass123');
  55. component.confirmPassword.set('SafePass123');
  56. await component.resetPassword();
  57. expect(functions.admin).toHaveBeenCalledWith('resetPassword', { className: '_User', objectId: 'user-object', newPassword: 'SafePass123' });
  58. expect(component.passwordMessage()).toContain('已安全重置');
  59. });
  60. it('uses the dedicated ordinary-account operation without generic role fields', async () => {
  61. component.className.set('_User');
  62. component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] });
  63. component.createUsername.set('student-1001');
  64. component.createPassword.set('SafePass123');
  65. component.createConfirmPassword.set('SafePass123');
  66. component.createDisplayName.set('新学员');
  67. component.createMobile.set('13800138000');
  68. functions.admin.and.callFake(async (operation: string) => operation === 'createUser'
  69. ? { objectId: 'new-user', userId: 1001, groupId: 1 }
  70. : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] });
  71. await component.createUser();
  72. expect(functions.admin.calls.first().args).toEqual(['createUser', {
  73. username: 'student-1001', password: 'SafePass123', displayName: '新学员', mobile: '13800138000',
  74. }]);
  75. expect(component.editorOpen()).toBeFalse();
  76. });
  77. it('rejects mismatched account passwords before calling the cloud function', async () => {
  78. component.className.set('_User');
  79. component.createUsername.set('student-1002');
  80. component.createPassword.set('SafePass123');
  81. component.createConfirmPassword.set('Different123');
  82. await component.createUser();
  83. expect(functions.admin).not.toHaveBeenCalled();
  84. expect(component.createUserError()).toContain('不一致');
  85. });
  86. it('uses the protected batch operation to lock a user and reports revoked sessions', async () => {
  87. spyOn(window, 'confirm').and.returnValue(true);
  88. component.className.set('_User');
  89. component.draftId.set('member-object');
  90. component.draft.set({ objectId: 'member-object', isDisabled: false });
  91. component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] });
  92. functions.admin.and.callFake(async (operation: string) => operation === 'userBatch'
  93. ? { action: 'lock', updated: 1, revokedSessions: 2, results: [{ objectId: 'member-object', isDisabled: true }] }
  94. : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] });
  95. await component.setUserDisabled(true);
  96. expect(functions.admin.calls.first().args).toEqual(['userBatch', { className: '_User', action: 'lock', objectIds: ['member-object'], reason: '' }]);
  97. expect(component.draft()['isDisabled']).toBeTrue();
  98. expect(component.userActionMessage()).toContain('2 个会话');
  99. });
  100. it('moves a user through the dedicated group synchronization operation', async () => {
  101. component.className.set('_User');
  102. component.draftId.set('member-object');
  103. component.draft.set({ objectId: 'member-object', legacyGroupId: 1 });
  104. component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] });
  105. component.selectedUserGroupId.set(3);
  106. functions.admin.and.callFake(async (operation: string) => operation === 'userBatch'
  107. ? { action: 'move', updated: 1, revokedSessions: 0, results: [{ objectId: 'member-object', legacyGroupId: 3, legacyUserData: { GroupID: 3 } }] }
  108. : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] });
  109. await component.moveUserGroup();
  110. expect(functions.admin.calls.first().args).toEqual(['userBatch', { className: '_User', action: 'move', objectIds: ['member-object'], groupId: 3 }]);
  111. expect(component.draft()['legacyGroupId']).toBe(3);
  112. });
  113. it('routes group creation through the atomic group operation', async () => {
  114. component.className.set('Group');
  115. component.schema.set({ className: 'Group', label: '用户组', writable: true, supportsSoftDelete: false, fields: [] });
  116. component.draft.set({ groupName: '普通会员', parentGroupId: 0, regSelect: true });
  117. functions.admin.and.callFake(async (operation: string) => operation === 'saveGroup'
  118. ? { objectId: 'group-object', groupId: 1, groupName: '普通会员' }
  119. : { className: 'Group', page: 1, pageSize: 20, total: 1, results: [] });
  120. await component.save();
  121. expect(functions.admin.calls.first().args).toEqual(['saveGroup', {
  122. className: 'Group', objectId: undefined, fields: { groupName: '普通会员', parentGroupId: 0, regSelect: true },
  123. }]);
  124. });
  125. it('routes node creation through the structural cloud operation', async () => {
  126. component.className.set('Node');
  127. component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] });
  128. component.draft.set({ nodeName: '新栏目', nodeDir: 'new-node', parentId: 999, nodeId: 999 });
  129. component.targetNodeParentId.set(16);
  130. functions.admin.and.callFake(async (operation: string) => operation === 'saveNode'
  131. ? { objectId: 'node-object', nodeId: 401, nodeName: '新栏目', parentId: 16 }
  132. : { className: 'Node', page: 1, pageSize: 20, total: 1, results: [] });
  133. await component.save();
  134. expect(functions.admin.calls.first().args).toEqual(['saveNode', {
  135. className: 'Node', objectId: undefined, fields: { nodeName: '新栏目', nodeDir: 'new-node', parentId: 999, nodeId: 999 }, parentId: 16,
  136. }]);
  137. });
  138. it('moves a node through the cycle-protected batch operation', async () => {
  139. component.className.set('Node');
  140. component.draftId.set('node-object');
  141. component.draft.set({ objectId: 'node-object', nodeId: 40, parentId: 16, zstatus: 99 });
  142. component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] });
  143. component.targetNodeParentId.set(29);
  144. functions.admin.and.callFake(async (operation: string) => operation === 'nodeBatch'
  145. ? { action: 'move', updated: 1, results: [{ objectId: 'node-object', nodeId: 40, parentId: 29, zstatus: 99 }] }
  146. : { className: 'Node', page: 1, pageSize: 20, total: 1, results: [] });
  147. await component.applyNodeAction('move');
  148. expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'move', objectIds: ['node-object'], parentId: 29 }]);
  149. expect(component.draft()['parentId']).toBe(29);
  150. expect(component.nodeActionMessage()).toContain('29');
  151. });
  152. it('routes special creation through the structural cloud operation', async () => {
  153. component.className.set('Special');
  154. component.schema.set({ className: 'Special', label: '专题', writable: true, supportsSoftDelete: false, fields: [] });
  155. component.draft.set({ specName: '新专题', specDir: 'new-special', specId: 999, pid: 999 });
  156. component.targetSpecialParentId.set(1);
  157. functions.admin.and.callFake(async (operation: string) => operation === 'saveSpecial'
  158. ? { objectId: 'special-object', specId: 10, specName: '新专题', pid: 1 }
  159. : { className: 'Special', page: 1, pageSize: 20, total: 1, results: [] });
  160. await component.save();
  161. expect(functions.admin.calls.first().args).toEqual(['saveSpecial', {
  162. className: 'Special', objectId: undefined, fields: { specName: '新专题', specDir: 'new-special', specId: 999, pid: 999 }, pid: 1,
  163. }]);
  164. });
  165. it('moves a special through the dedicated batch operation', async () => {
  166. component.className.set('Special');
  167. component.draftId.set('special-object');
  168. component.draft.set({ objectId: 'special-object', specId: 10, pid: 1 });
  169. component.schema.set({ className: 'Special', label: '专题', writable: true, supportsSoftDelete: false, fields: [] });
  170. component.targetSpecialParentId.set(0);
  171. functions.admin.and.callFake(async (operation: string) => operation === 'specialBatch'
  172. ? { action: 'move', updated: 1, results: [{ objectId: 'special-object', specId: 10, pid: 0 }] }
  173. : { className: 'Special', page: 1, pageSize: 20, total: 1, results: [] });
  174. await component.moveSpecial();
  175. expect(functions.admin.calls.first().args).toEqual(['specialBatch', { className: 'Special', action: 'move', objectIds: ['special-object'], pid: 0 }]);
  176. expect(component.specialActionMessage()).toContain('0');
  177. });
  178. it('updates existing model metadata through the protected operation', async () => {
  179. component.className.set('Model');
  180. component.draftId.set('model-object');
  181. component.schema.set({ className: 'Model', label: '内容模型', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  182. component.draft.set({ objectId: 'model-object', modelId: 2, tableName: 'ZL_C_Article', modelName: '文章模型', description: '安全说明' });
  183. functions.admin.and.callFake(async (operation: string) => operation === 'saveModelMetadata'
  184. ? { objectId: 'model-object', modelId: 2, modelName: '文章模型', description: '安全说明' }
  185. : { className: 'Model', page: 1, pageSize: 20, total: 1, results: [] });
  186. await component.save();
  187. expect(functions.admin.calls.first().args).toEqual(['saveModelMetadata', {
  188. className: 'Model', objectId: 'model-object', fields: { objectId: 'model-object', modelId: 2, tableName: 'ZL_C_Article', modelName: '文章模型', description: '安全说明' },
  189. }]);
  190. });
  191. it('updates existing model-field presentation metadata without structural writes', async () => {
  192. component.className.set('ModelField');
  193. component.draftId.set('field-object');
  194. component.schema.set({ className: 'ModelField', label: '模型字段', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  195. component.draft.set({ objectId: 'field-object', fieldId: 3, modelId: 2, fieldName: 'author', fieldType: 'TextType', fieldAlias: '作者' });
  196. functions.admin.and.callFake(async (operation: string) => operation === 'saveModelFieldMetadata'
  197. ? { objectId: 'field-object', fieldId: 3, fieldAlias: '作者' }
  198. : { className: 'ModelField', page: 1, pageSize: 20, total: 1, results: [] });
  199. await component.save();
  200. expect(functions.admin.calls.first().args).toEqual(['saveModelFieldMetadata', {
  201. className: 'ModelField', objectId: 'field-object', fields: { objectId: 'field-object', fieldId: 3, modelId: 2, fieldName: 'author', fieldType: 'TextType', fieldAlias: '作者' },
  202. }]);
  203. });
  204. it('updates an existing guestbook entry through the dedicated editor', async () => {
  205. component.className.set('Guestbook');
  206. component.draftId.set('guest-object');
  207. component.schema.set({ className: 'Guestbook', label: '留言', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  208. component.draft.set({ objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' });
  209. functions.admin.and.callFake(async (operation: string) => operation === 'saveGuestbook'
  210. ? { objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' }
  211. : { className: 'Guestbook', page: 1, pageSize: 20, total: 1, results: [] });
  212. await component.save();
  213. expect(functions.admin.calls.first().args).toEqual(['saveGuestbook', {
  214. className: 'Guestbook', objectId: 'guest-object', fields: { objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' },
  215. }]);
  216. });
  217. it('audits a guestbook entry through the workflow operation', async () => {
  218. component.className.set('Guestbook');
  219. component.draftId.set('guest-object');
  220. component.draft.set({ objectId: 'guest-object', gid: 10, status: 0 });
  221. component.schema.set({ className: 'Guestbook', label: '留言', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  222. functions.admin.and.callFake(async (operation: string) => operation === 'guestbookBatch'
  223. ? { action: 'audit', updated: 1, results: [{ objectId: 'guest-object', gid: 10, status: 99 }] }
  224. : { className: 'Guestbook', page: 1, pageSize: 20, total: 1, results: [] });
  225. await component.applyGuestbookAction('audit');
  226. expect(functions.admin.calls.first().args).toEqual(['guestbookBatch', { className: 'Guestbook', action: 'audit', objectIds: ['guest-object'] }]);
  227. expect(component.selectedGuestbookStatus()).toBe(99);
  228. });
  229. it('publishes an admin reply through the parent-safe cloud operation', async () => {
  230. component.className.set('Guestbook');
  231. component.draftId.set('guest-object');
  232. component.guestbookReplyTitle.set('回复标题');
  233. component.guestbookReplyContent.set('回复正文');
  234. functions.admin.and.resolveTo({ objectId: 'reply-object', gid: 11, parentid: 10, status: 99 });
  235. await component.replyGuestbook();
  236. expect(functions.admin).toHaveBeenCalledWith('guestbookReply', { className: 'Guestbook', parentObjectId: 'guest-object', title: '回复标题', content: '回复正文' });
  237. expect(component.guestbookActionMessage()).toContain('已发布');
  238. });
  239. it('updates content workflow state through the dedicated batch operation', async () => {
  240. component.className.set('CommonModel');
  241. component.draftId.set('content-object');
  242. component.draft.set({ objectId: 'content-object', status: 0, nodeId: 29 });
  243. component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] });
  244. component.selectedContentStatus.set(99);
  245. functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
  246. ? { action: 'status', updated: 1, results: [{ objectId: 'content-object', status: 99, nodeId: 29 }] }
  247. : { className: 'CommonModel', page: 1, pageSize: 20, total: 1, results: [] });
  248. await component.applyContentAction('status');
  249. expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-object'], status: 99 }]);
  250. expect(component.contentStatusValue()).toBe(99);
  251. expect(component.contentActionMessage()).toContain('已审核');
  252. });
  253. it('selects and clears all records on the current page', () => {
  254. component.className.set('CommonModel');
  255. component.page.set({ className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [{ objectId: 'content-a' }, { objectId: 'content-b' }] });
  256. component.togglePageSelection(true);
  257. expect(component.selectedIds()).toEqual(['content-a', 'content-b']);
  258. expect(component.allPageSelected()).toBeTrue();
  259. component.toggleSelection('content-a', false);
  260. expect(component.selectedIds()).toEqual(['content-b']);
  261. expect(component.allPageSelected()).toBeFalse();
  262. });
  263. it('sends all selected content ids through the bulk workflow operation', async () => {
  264. component.className.set('CommonModel');
  265. component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] });
  266. component.selectedIds.set(['content-a', 'content-b']);
  267. component.selectedContentStatus.set(99);
  268. functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
  269. ? { action: 'status', updated: 2, results: [] }
  270. : { className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [] });
  271. await component.applyBulkContentAction('status');
  272. expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-a', 'content-b'], status: 99 }]);
  273. expect(component.bulkMessage()).toContain('2 条内容');
  274. expect(component.selectedIds()).toEqual([]);
  275. });
  276. it('recovers all selected nodes through the dedicated batch operation', async () => {
  277. component.className.set('Node');
  278. component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] });
  279. component.selectedIds.set(['node-a', 'node-b']);
  280. functions.admin.and.callFake(async (operation: string) => operation === 'nodeBatch'
  281. ? { action: 'recover', updated: 2, results: [] }
  282. : { className: 'Node', page: 1, pageSize: 20, total: 2, results: [] });
  283. await component.applyBulkNodeAction('recover');
  284. expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'recover', objectIds: ['node-a', 'node-b'] }]);
  285. expect(component.bulkMessage()).toContain('2 个栏目');
  286. });
  287. });