admin-resource.component.spec.ts 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994
  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('creates a guest category through the atomic category operation', async () => {
  240. component.className.set('Guestcate');
  241. component.schema.set({ className: 'Guestcate', label: '留言分类', writable: true, supportsSoftDelete: false, fields: [] });
  242. component.draft.set({ catename: '新贴吧版块', gtype: 0, parentId: 999, cateid: 999 });
  243. component.guestCategoryType.set(1);
  244. component.targetGuestCategoryParentId.set(7);
  245. functions.admin.and.callFake(async (operation: string) => operation === 'saveGuestCategory'
  246. ? { objectId: 'category-object', cateid: 20, catename: '新贴吧版块', gtype: 1, parentId: 7 }
  247. : { className: 'Guestcate', page: 1, pageSize: 20, total: 1, results: [] });
  248. await component.save();
  249. expect(functions.admin.calls.first().args).toEqual(['saveGuestCategory', {
  250. className: 'Guestcate', objectId: undefined, fields: { catename: '新贴吧版块', gtype: 0, parentId: 999, cateid: 999 }, gtype: 1, parentId: 7,
  251. }]);
  252. });
  253. it('creates a question category through the atomic structural operation', async () => {
  254. component.className.set('ExamClass');
  255. component.schema.set({ className: 'ExamClass', label: '试题分类', writable: true, supportsSoftDelete: false, fields: [] });
  256. component.draft.set({ cClassName: '英语', cId: 999, cClassid: 999 });
  257. component.targetExamClassParentId.set(7);
  258. component.examClassType.set(2);
  259. component.examClassOrder.set(8);
  260. functions.admin.and.callFake(async (operation: string) => operation === 'saveExamClass'
  261. ? { objectId: 'exam-class-object', cId: 20, cClassName: '英语', cClassid: 7, cClassType: 2, cOrderBy: 8 }
  262. : { className: 'ExamClass', page: 1, pageSize: 20, total: 1, results: [] });
  263. await component.save();
  264. expect(functions.admin.calls.first().args).toEqual(['saveExamClass', {
  265. className: 'ExamClass', objectId: undefined, fields: { cClassName: '英语', cId: 999, cClassid: 999 }, parentId: 7, classType: 2, orderId: 8,
  266. }]);
  267. });
  268. it('creates an exam point through the atomic structural operation', async () => {
  269. component.className.set('ExamPoint');
  270. component.schema.set({ className: 'ExamPoint', label: '考点管理', writable: true, supportsSoftDelete: false, fields: [] });
  271. component.draft.set({ testPoint: '第一考点', id: 999, tid: 999 });
  272. component.targetExamPointParentId.set(3);
  273. component.examPointOrder.set(5);
  274. functions.admin.and.callFake(async (operation: string) => operation === 'saveExamPoint'
  275. ? { objectId: 'exam-point-object', id: 20, testPoint: '第一考点', tid: 3, orderBy: 5 }
  276. : { className: 'ExamPoint', page: 1, pageSize: 20, total: 1, results: [] });
  277. await component.save();
  278. expect(functions.admin.calls.first().args).toEqual(['saveExamPoint', {
  279. className: 'ExamPoint', objectId: undefined, fields: { testPoint: '第一考点', id: 999, tid: 999 }, parentId: 3, orderId: 5,
  280. }]);
  281. });
  282. it('creates a knowledge node through the subject-safe structural operation', async () => {
  283. component.className.set('QuestionsKnowledge');
  284. component.schema.set({ className: 'QuestionsKnowledge', label: '知识点', writable: true, supportsSoftDelete: false, fields: [] });
  285. component.draft.set({ kName: '现在完成时', kId: 999, kClassId: 999, status: 1, grade: 3, isSys: 0 });
  286. component.targetKnowledgeClassId.set(7);
  287. component.targetKnowledgeParentId.set(2);
  288. component.knowledgeOrder.set(5);
  289. functions.admin.and.callFake(async (operation: string) => operation === 'saveKnowledge'
  290. ? { objectId: 'knowledge-object', kId: 20, kName: '现在完成时', kClassId: 7, pid: 2, kOrderBy: 5 }
  291. : { className: 'QuestionsKnowledge', page: 1, pageSize: 20, total: 1, results: [] });
  292. await component.save();
  293. expect(functions.admin.calls.first().args).toEqual(['saveKnowledge', {
  294. className: 'QuestionsKnowledge', objectId: undefined, fields: { kName: '现在完成时', kId: 999, kClassId: 999, status: 1, grade: 3, isSys: 0 }, classId: 7, parentId: 2, orderId: 5,
  295. }]);
  296. });
  297. it('creates an exam teacher through the subject-safe operation', async () => {
  298. component.className.set('ExTeacher');
  299. component.schema.set({ className: 'ExTeacher', label: '考试教师', writable: true, supportsSoftDelete: false, fields: [] });
  300. component.draft.set({ tname: '张老师', tclsss: 999, post: '高级教师', teach: '英语', remark: '简介' });
  301. component.targetTeacherClassId.set(7);
  302. functions.admin.and.callFake(async (operation: string) => operation === 'saveExamTeacher'
  303. ? { objectId: 'teacher-object', id: 20, tname: '张老师', tclsss: 7 }
  304. : { className: 'ExTeacher', page: 1, pageSize: 20, total: 1, results: [] });
  305. await component.save();
  306. expect(functions.admin.calls.first().args).toEqual(['saveExamTeacher', {
  307. className: 'ExTeacher', objectId: undefined, fields: { tname: '张老师', tclsss: 999, post: '高级教师', teach: '英语', remark: '简介' }, classId: 7,
  308. }]);
  309. });
  310. it('creates a dictionary category through the collision-safe operation', async () => {
  311. component.className.set('Datadiccategory');
  312. component.schema.set({ className: 'Datadiccategory', label: '数据字典分类', writable: true, supportsSoftDelete: false, fields: [] });
  313. component.draft.set({ categoryname: '学习状态', diccateid: 999, isused: true });
  314. functions.admin.and.callFake(async (operation: string) => operation === 'saveDictionaryCategory'
  315. ? { objectId: 'dictionary-category-object', diccateid: 20, categoryname: '学习状态', isused: true }
  316. : { className: 'Datadiccategory', page: 1, pageSize: 20, total: 1, results: [] });
  317. await component.save();
  318. expect(functions.admin.calls.first().args).toEqual(['saveDictionaryCategory', {
  319. className: 'Datadiccategory', objectId: undefined, fields: { categoryname: '学习状态', diccateid: 999, isused: true },
  320. }]);
  321. });
  322. it('creates a dictionary item through the category-safe operation', async () => {
  323. component.className.set('Datadic');
  324. component.schema.set({ className: 'Datadic', label: '数据字典项', writable: true, supportsSoftDelete: false, fields: [] });
  325. component.draft.set({ dicname: '已完成', dicid: 999, diccate: 999, isused: true });
  326. component.targetDictionaryCategoryId.set(20);
  327. functions.admin.and.callFake(async (operation: string) => operation === 'saveDictionaryItem'
  328. ? { objectId: 'dictionary-item-object', dicid: 30, diccate: 20, dicname: '已完成', isused: true }
  329. : { className: 'Datadic', page: 1, pageSize: 20, total: 1, results: [] });
  330. await component.save();
  331. expect(functions.admin.calls.first().args).toEqual(['saveDictionaryItem', {
  332. className: 'Datadic', objectId: undefined, fields: { dicname: '已完成', dicid: 999, diccate: 999, isused: true }, categoryId: 20,
  333. }]);
  334. });
  335. it('batch disables selected dictionary records through the dedicated operation', async () => {
  336. component.className.set('Datadic');
  337. component.selectedIds.set(['dictionary-a', 'dictionary-b']);
  338. functions.admin.and.callFake(async (operation: string) => operation === 'dictionaryBatch'
  339. ? { action: 'disable', updated: 2, results: [] }
  340. : { className: 'Datadic', page: 1, pageSize: 20, total: 2, results: [] });
  341. await component.applyBulkDictionaryAction('disable');
  342. expect(functions.admin.calls.first().args).toEqual(['dictionaryBatch', { className: 'Datadic', action: 'disable', objectIds: ['dictionary-a', 'dictionary-b'] }]);
  343. expect(component.bulkMessage()).toContain('2 条字典记录');
  344. });
  345. it('creates a multi-level dictionary category through the dedicated operation', async () => {
  346. component.className.set('GradeCate');
  347. component.schema.set({ className: 'GradeCate', label: '多级字典分类', writable: true, supportsSoftDelete: false, fields: [] });
  348. component.draft.set({ cateName: '地区联动', gradeField: '省|市', cateId: 999, remark: '两级地区' });
  349. functions.admin.and.callFake(async (operation: string) => operation === 'saveGradeCategory'
  350. ? { objectId: 'grade-category-object', cateId: 20, cateName: '地区联动', gradeField: '省|市' }
  351. : { className: 'GradeCate', page: 1, pageSize: 20, total: 1, results: [] });
  352. await component.save();
  353. expect(functions.admin.calls.first().args).toEqual(['saveGradeCategory', {
  354. className: 'GradeCate', objectId: undefined, fields: { cateName: '地区联动', gradeField: '省|市', cateId: 999, remark: '两级地区' },
  355. }]);
  356. });
  357. it('creates a multi-level dictionary option through the parent-safe operation', async () => {
  358. component.className.set('Grade');
  359. component.schema.set({ className: 'Grade', label: '多级字典选项', writable: true, supportsSoftDelete: false, fields: [] });
  360. component.draft.set({ gradeName: '杭州市', gradeId: 999, cate: 999, parentId: 999, grade: 999 });
  361. component.targetGradeCategoryId.set(20); component.targetGradeParentId.set(30);
  362. functions.admin.and.callFake(async (operation: string) => operation === 'saveGradeOption'
  363. ? { objectId: 'grade-option-object', gradeId: 40, cate: 20, parentId: 30, grade: 2, gradeName: '杭州市' }
  364. : { className: 'Grade', page: 1, pageSize: 20, total: 1, results: [] });
  365. await component.save();
  366. expect(functions.admin.calls.first().args).toEqual(['saveGradeOption', {
  367. className: 'Grade', objectId: undefined, fields: { gradeName: '杭州市', gradeId: 999, cate: 999, parentId: 999, grade: 999 }, categoryId: 20, parentId: 30,
  368. }]);
  369. });
  370. it('batch deletes selected multi-level dictionary options with the dedicated operation', async () => {
  371. component.className.set('Grade'); component.selectedIds.set(['grade-a', 'grade-b']); spyOn(window, 'confirm').and.returnValue(true);
  372. functions.admin.and.callFake(async (operation: string) => operation === 'gradeBatch'
  373. ? { action: 'delete', updated: 2, results: [] }
  374. : { className: 'Grade', page: 1, pageSize: 20, total: 0, results: [] });
  375. await component.applyBulkGradeDelete();
  376. expect(functions.admin.calls.first().args).toEqual(['gradeBatch', { className: 'Grade', action: 'delete', objectIds: ['grade-a', 'grade-b'] }]);
  377. expect(component.bulkMessage()).toContain('2 条多级字典记录');
  378. });
  379. it('updates an existing currency through the validated dedicated operation', async () => {
  380. component.className.set('Currency');
  381. component.schema.set({ className: 'Currency', label: '货币管理', writable: true, supportsSoftDelete: false, fields: [] });
  382. component.draftId.set('currency-object');
  383. component.draft.set({ title: '测试币', currencyName: 'TST', currencySymbol: 'T$', currentExchange: 1.25, remark: '测试', id: 999 });
  384. functions.admin.and.callFake(async (operation: string) => operation === 'saveCurrency'
  385. ? { objectId: 'currency-object', id: 20, title: '测试币', currencyName: 'TST' }
  386. : { className: 'Currency', page: 1, pageSize: 20, total: 1, results: [] });
  387. await component.save();
  388. expect(functions.admin.calls.first().args).toEqual(['saveCurrency', {
  389. className: 'Currency', objectId: 'currency-object', fields: { title: '测试币', currencyName: 'TST', currencySymbol: 'T$', currentExchange: 1.25, remark: '测试', id: 999 },
  390. }]);
  391. });
  392. it('batch deletes selected currencies through the tenant-safe operation', async () => {
  393. component.className.set('Currency'); component.selectedIds.set(['currency-a']); spyOn(window, 'confirm').and.returnValue(true);
  394. functions.admin.and.callFake(async (operation: string) => operation === 'currencyBatch'
  395. ? { action: 'delete', updated: 1, results: [] }
  396. : { className: 'Currency', page: 1, pageSize: 20, total: 0, results: [] });
  397. await component.applyBulkCurrencyDelete();
  398. expect(functions.admin.calls.first().args).toEqual(['currencyBatch', { className: 'Currency', action: 'delete', objectIds: ['currency-a'] }]);
  399. expect(component.bulkMessage()).toContain('1 条货币记录');
  400. });
  401. it('updates an existing holiday through the validated dedicated operation', async () => {
  402. component.className.set('SysHoliday');
  403. component.schema.set({ className: 'SysHoliday', label: '节假日管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  404. component.draftId.set('holiday-object');
  405. component.draft.set({ name: '春节', htype: '农历', hdate: '01月01日', hdateStart: 1, hdateEnd: 6, zstatus: 99, id: 999, sourceKey: 'blocked' });
  406. functions.admin.and.callFake(async (operation: string) => operation === 'saveHoliday'
  407. ? { objectId: 'holiday-object', id: 1, name: '春节' }
  408. : { className: 'SysHoliday', page: 1, pageSize: 20, total: 1, results: [] });
  409. await component.save();
  410. expect(functions.admin.calls.first().args).toEqual(['saveHoliday', {
  411. className: 'SysHoliday', objectId: 'holiday-object', fields: { name: '春节', htype: '农历', hdate: '01月01日', hdateStart: 1, hdateEnd: 6, zstatus: 99, id: 999, sourceKey: 'blocked' },
  412. }]);
  413. });
  414. it('batch deletes selected holidays through the tenant-safe operation', async () => {
  415. component.className.set('SysHoliday'); component.selectedIds.set(['holiday-a', 'holiday-b']); spyOn(window, 'confirm').and.returnValue(true);
  416. functions.admin.and.callFake(async (operation: string) => operation === 'holidayBatch'
  417. ? { action: 'delete', updated: 2, results: [] }
  418. : { className: 'SysHoliday', page: 1, pageSize: 20, total: 0, results: [] });
  419. await component.applyBulkHolidayDelete();
  420. expect(functions.admin.calls.first().args).toEqual(['holidayBatch', { className: 'SysHoliday', action: 'delete', objectIds: ['holiday-a', 'holiday-b'] }]);
  421. expect(component.bulkMessage()).toContain('2 条节假日记录');
  422. });
  423. it('loads shortcut navigation through the dedicated filtered operation', async () => {
  424. component.className.set('Search');
  425. component.schema.set({ className: 'Search', label: '后台快捷入口', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  426. component.filterSearchNavigationType.set(2); component.filterSearchNavigationState.set(1); component.filterSearchNavigationElite.set(0); component.search.set('学习');
  427. functions.admin.and.resolveTo({ className: 'Search', page: 2, pageSize: 20, total: 21, results: [] });
  428. await component.load(2);
  429. expect(functions.admin.calls.first().args).toEqual(['searchNavigations', { className: 'Search', page: 2, pageSize: 20, search: '学习', type: 2, state: 1, elite: 0 }]);
  430. });
  431. it('updates an existing shortcut through the validated dedicated operation', async () => {
  432. component.className.set('Search');
  433. component.schema.set({ className: 'Search', label: '后台快捷入口', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  434. component.draftId.set('search-object');
  435. component.draft.set({ name: '学习记录', fileUrl: '/admin/resources/DailyStudyRecord', ico: 'list', openType: 0, mobile: 1, size: 2, bkcolor: '#1E86EA', userGroup: '1,2', eliteLevel: 1, id: 19, state: 1 });
  436. functions.admin.and.callFake(async (operation: string) => operation === 'saveSearchNavigation'
  437. ? { objectId: 'search-object', id: 19, name: '学习记录' }
  438. : { className: 'Search', page: 1, pageSize: 20, total: 1, results: [] });
  439. await component.save();
  440. expect(functions.admin.calls.first().args).toEqual(['saveSearchNavigation', {
  441. className: 'Search', objectId: 'search-object', fields: { name: '学习记录', fileUrl: '/admin/resources/DailyStudyRecord', ico: 'list', openType: 0, mobile: 1, size: 2, bkcolor: '#1E86EA', userGroup: '1,2', eliteLevel: 1, id: 19, state: 1 },
  442. }]);
  443. });
  444. it('orders selected shortcuts through the dedicated batch operation', async () => {
  445. component.className.set('Search'); component.selectedIds.set(['search-a', 'search-b']); component.searchNavigationOrderStart.set(7);
  446. functions.admin.and.callFake(async (operation: string) => operation === 'searchNavigationBatch'
  447. ? { action: 'order', updated: 2, results: [] }
  448. : { className: 'Search', page: 1, pageSize: 20, total: 0, results: [] });
  449. await component.applyBulkSearchNavigationAction('order');
  450. expect(functions.admin.calls.first().args).toEqual(['searchNavigationBatch', { className: 'Search', action: 'order', objectIds: ['search-a', 'search-b'], orders: [{ objectId: 'search-a', orderId: 7 }, { objectId: 'search-b', orderId: 8 }] }]);
  451. expect(component.bulkMessage()).toContain('2 条快捷入口');
  452. });
  453. it('loads advertising content through the dedicated zone and status filters', async () => {
  454. component.className.set('AdInfo');
  455. component.schema.set({ className: 'AdInfo', label: '广告内容管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  456. component.filterAdInfoZoneId.set('5'); component.filterAdStatus.set(99); component.search.set('横幅');
  457. functions.admin.and.resolveTo({ className: 'AdInfo', page: 2, pageSize: 20, total: 21, results: [] });
  458. await component.load(2);
  459. expect(functions.admin.calls.first().args).toEqual(['adInfos', { className: 'AdInfo', page: 2, pageSize: 20, search: '横幅', zoneId: '5', status: 99 }]);
  460. });
  461. it('updates an existing advertisement through the zone-safe operation', async () => {
  462. component.className.set('AdInfo');
  463. component.schema.set({ className: 'AdInfo', label: '广告内容管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  464. component.draftId.set('ad-info-object'); component.targetAdZoneId.set('5');
  465. component.draft.set({ objectId: 'ad-info-object', id: 5, name: '矩形横幅广告', zoneId: '999', priority: '2', zstatus: 99, sdate: '2026-08-01T00:00:00.000Z', edate: '2026-09-01T00:00:00.000Z' });
  466. functions.admin.and.callFake(async (operation: string) => operation === 'saveAdInfo'
  467. ? { objectId: 'ad-info-object', id: 5, name: '矩形横幅广告', zoneId: '5' }
  468. : { className: 'AdInfo', page: 1, pageSize: 20, total: 1, results: [] });
  469. await component.save();
  470. expect(functions.admin.calls.first().args).toEqual(['saveAdInfo', {
  471. className: 'AdInfo', objectId: 'ad-info-object', fields: { objectId: 'ad-info-object', id: 5, name: '矩形横幅广告', zoneId: '999', priority: '2', zstatus: 99, sdate: '2026-08-01T00:00:00.000Z', edate: '2026-09-01T00:00:00.000Z' }, zoneId: '5',
  472. }]);
  473. });
  474. it('pauses selected advertising zones through the dedicated batch operation', async () => {
  475. component.className.set('AdZone'); component.selectedIds.set(['zone-a', 'zone-b']);
  476. functions.admin.and.callFake(async (operation: string) => operation === 'adZoneBatch'
  477. ? { action: 'pause', updated: 2, results: [] }
  478. : { className: 'AdZone', page: 1, pageSize: 20, total: 2, results: [] });
  479. await component.applyBulkAdvertisementAction('pause');
  480. expect(functions.admin.calls.first().args).toEqual(['adZoneBatch', { className: 'AdZone', action: 'pause', objectIds: ['zone-a', 'zone-b'] }]);
  481. expect(component.bulkMessage()).toContain('2 条广告位');
  482. });
  483. it('loads active campaign previews without executing legacy HTML', async () => {
  484. component.className.set('AdZone'); component.draftId.set('zone-object');
  485. functions.admin.and.resolveTo({ zone: { objectId: 'zone-object', id: '1' }, total: 1, results: [{ objectId: 'ad-object', name: '文字广告', html: '<script>unsafe()</script>' }] });
  486. await component.loadAdPreview();
  487. expect(functions.admin).toHaveBeenCalledWith('adPreview', { zoneObjectId: 'zone-object' });
  488. expect(component.adPreview()[0]['html']).toContain('<script>');
  489. });
  490. it('loads font-shape materials through the dedicated category filter', async () => {
  491. component.className.set('FontPicShape');
  492. component.schema.set({ className: 'FontPicShape', label: '字体图形素材', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  493. component.filterFontShapeTypeId.set(2); component.search.set('鹿');
  494. functions.admin.and.resolveTo({ className: 'FontPicShape', page: 2, pageSize: 20, total: 21, results: [] });
  495. await component.load(2);
  496. expect(functions.admin.calls.first().args).toEqual(['fontShapes', { className: 'FontPicShape', page: 2, pageSize: 20, search: '鹿', typeId: 2 }]);
  497. });
  498. it('updates existing font-shape metadata without sending the blocked file field', async () => {
  499. component.className.set('FontPicShape');
  500. component.schema.set({ className: 'FontPicShape', label: '字体图形素材', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  501. component.draftId.set('shape-object'); component.targetFontShapeTypeId.set(2);
  502. component.draft.set({ objectId: 'shape-object', id: 2, name: '鹿', typeId: 999, shape: '/legacy/deer.png', remarks: '动物' });
  503. functions.admin.and.callFake(async (operation: string) => operation === 'saveFontShape'
  504. ? { objectId: 'shape-object', id: 2, name: '鹿', typeId: 2, shape: '/legacy/deer.png' }
  505. : { className: 'FontPicShape', page: 1, pageSize: 20, total: 1, results: [] });
  506. await component.save();
  507. expect(functions.admin.calls.first().args).toEqual(['saveFontShape', {
  508. className: 'FontPicShape', objectId: 'shape-object', fields: { objectId: 'shape-object', id: 2, name: '鹿', typeId: 999, shape: '/legacy/deer.png', remarks: '动物' }, typeId: 2,
  509. }]);
  510. });
  511. it('deletes selected font-shape categories through the reference-safe operation', async () => {
  512. component.className.set('FontPicShapeType'); component.selectedIds.set(['shape-type-a']); spyOn(window, 'confirm').and.returnValue(true);
  513. functions.admin.and.callFake(async (operation: string) => operation === 'fontShapeTypeBatch'
  514. ? { action: 'delete', updated: 1, results: [] }
  515. : { className: 'FontPicShapeType', page: 1, pageSize: 20, total: 0, results: [] });
  516. await component.applyBulkFontShapeDelete();
  517. expect(functions.admin.calls.first().args).toEqual(['fontShapeTypeBatch', { className: 'FontPicShapeType', action: 'delete', objectIds: ['shape-type-a'] }]);
  518. expect(component.bulkMessage()).toContain('1 条图形分类');
  519. });
  520. it('loads survey questions through the selected legacy survey id', async () => {
  521. component.className.set('DesignQuestion');
  522. component.schema.set({ className: 'DesignQuestion', label: '问卷题目', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  523. component.filterSurveyId.set(2); component.search.set('阅读');
  524. functions.admin.and.resolveTo({ className: 'DesignQuestion', page: 2, pageSize: 20, total: 21, results: [] });
  525. await component.load(2, false);
  526. expect(functions.admin.calls.first().args).toEqual(['surveyQuestions', { className: 'DesignQuestion', page: 2, pageSize: 20, search: '阅读', askId: 2 }]);
  527. });
  528. it('updates existing survey metadata through the dedicated operation', async () => {
  529. component.className.set('DesignAsk');
  530. component.schema.set({ className: 'DesignAsk', label: '问卷调查', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  531. component.draftId.set('survey-object'); component.draft.set({ objectId: 'survey-object', id: 2, title: '第二组问题', zstatus: 99 });
  532. functions.admin.and.callFake(async (operation: string) => operation === 'saveSurvey'
  533. ? { objectId: 'survey-object', id: 2, title: '第二组问题', zstatus: 99 }
  534. : { className: 'DesignAsk', page: 1, pageSize: 20, total: 1, results: [] });
  535. await component.save();
  536. expect(functions.admin.calls.first().args).toEqual(['saveSurvey', { className: 'DesignAsk', objectId: 'survey-object', fields: { objectId: 'survey-object', id: 2, title: '第二组问题', zstatus: 99 } }]);
  537. });
  538. it('starts selected surveys through the dedicated batch operation', async () => {
  539. component.className.set('DesignAsk'); component.selectedIds.set(['survey-a']);
  540. functions.admin.and.callFake(async (operation: string) => operation === 'surveyBatch'
  541. ? { action: 'start', updated: 1, results: [] }
  542. : { className: 'DesignAsk', page: 1, pageSize: 20, total: 1, results: [] });
  543. await component.applyBulkSurveyAction('start');
  544. expect(functions.admin.calls.first().args).toEqual(['surveyBatch', { className: 'DesignAsk', action: 'start', objectIds: ['survey-a'] }]);
  545. expect(component.bulkMessage()).toContain('1 条问卷记录');
  546. });
  547. it('loads a privacy-safe aggregate for the selected survey results', async () => {
  548. component.className.set('DesignAnswer'); component.filterSurveyId.set(2);
  549. functions.admin.and.resolveTo({ survey: { id: 2, title: '第二组问题' }, responseCount: 35, invalidAnswers: 1, questions: [] });
  550. await component.loadSurveyChart();
  551. expect(functions.admin).toHaveBeenCalledWith('surveyResultChart', { askId: 2 });
  552. expect(component.surveyChart()?.responseCount).toBe(35);
  553. });
  554. it('loads customer-service seats through the dedicated operation', async () => {
  555. component.className.set('ServiceSeat');
  556. component.schema.set({ className: 'ServiceSeat', label: '客服席位', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
  557. component.search.set('admin'); functions.admin.and.resolveTo({ className: 'ServiceSeat', page: 1, pageSize: 20, total: 1, results: [] });
  558. await component.load(1, false);
  559. expect(functions.admin).toHaveBeenCalledWith('serviceSeats', { className: 'ServiceSeat', page: 1, pageSize: 20, search: 'admin' });
  560. });
  561. it('creates a service seat while binding an existing account', async () => {
  562. component.className.set('ServiceSeat');
  563. component.schema.set({ className: 'ServiceSeat', label: '客服席位', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
  564. component.draft.set({ sName: '在线客服', sDefault: 1, sIndex: 2 }); component.targetServiceSeatUsername.set('admin');
  565. functions.admin.and.callFake(async (operation: string) => operation === 'saveServiceSeat'
  566. ? { objectId: 'seat-object', sId: 2, sName: '在线客服', sRemrk: 'admin' }
  567. : { className: 'ServiceSeat', page: 1, pageSize: 20, total: 2, results: [] });
  568. await component.save();
  569. expect(functions.admin.calls.first().args).toEqual(['saveServiceSeat', { className: 'ServiceSeat', objectId: undefined, fields: { sName: '在线客服', sDefault: 1, sIndex: 2 }, username: 'admin' }]);
  570. });
  571. it('deletes selected service welcome messages through the scoped operation', async () => {
  572. component.className.set('Temp'); component.selectedIds.set(['code-object']); spyOn(window, 'confirm').and.returnValue(true);
  573. functions.admin.and.callFake(async (operation: string) => operation === 'serviceBatch'
  574. ? { action: 'delete', updated: 1, results: [] }
  575. : { className: 'Temp', page: 1, pageSize: 20, total: 0, results: [] });
  576. await component.applyBulkServiceDelete();
  577. expect(functions.admin.calls.first().args).toEqual(['serviceBatch', { className: 'Temp', action: 'delete', objectIds: ['code-object'] }]);
  578. expect(component.bulkMessage()).toContain('1 条客服记录');
  579. });
  580. it('loads stores through status-safe dedicated pagination', async () => {
  581. component.className.set('StoreApplication'); component.schema.set({ className: 'StoreApplication', label: '店铺管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); component.filterStoreStatus.set(99); component.search.set('逐浪');
  582. functions.admin.and.resolveTo({ className: 'StoreApplication', page: 1, pageSize: 20, total: 1, results: [] });
  583. await component.load(1, false);
  584. expect(functions.admin).toHaveBeenCalledWith('storeApplications', { className: 'StoreApplication', page: 1, pageSize: 20, search: '逐浪', status: 99 });
  585. });
  586. it('updates an existing store through account and style validation', async () => {
  587. component.className.set('StoreApplication'); component.schema.set({ className: 'StoreApplication', label: '店铺管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); component.draftId.set('store-object'); component.draft.set({ objectId: 'store-object', id: 1, storeName: '逐浪网店', userName: 'old' }); component.targetStoreUsername.set('admin'); component.targetStoreStyleId.set(2);
  588. functions.admin.and.callFake(async (operation: string) => operation === 'saveStoreApplication' ? { objectId: 'store-object', id: 1, storeName: '逐浪网店', userName: 'admin', storeStyleId: 2 } : { className: 'StoreApplication', page: 1, pageSize: 20, total: 1, results: [] });
  589. await component.save();
  590. expect(functions.admin.calls.first().args).toEqual(['saveStoreApplication', { className: 'StoreApplication', objectId: 'store-object', fields: { objectId: 'store-object', id: 1, storeName: '逐浪网店', userName: 'old' }, username: 'admin', styleId: 2 }]);
  591. });
  592. it('audits selected stores without exposing the blocked delete action', async () => {
  593. component.className.set('StoreApplication'); component.selectedIds.set(['store-a']); functions.admin.and.callFake(async (operation: string) => operation === 'storeBatch' ? { action: 'audit', updated: 1, results: [] } : { className: 'StoreApplication', page: 1, pageSize: 20, total: 1, results: [] });
  594. await component.applyBulkStoreAction('audit');
  595. expect(functions.admin.calls.first().args).toEqual(['storeBatch', { className: 'StoreApplication', action: 'audit', objectIds: ['store-a'] }]);
  596. expect(component.bulkMessage()).toContain('1 条店铺记录');
  597. });
  598. it('updates a forum post through the dedicated workflow operation', async () => {
  599. component.className.set('GuestBar');
  600. component.draftId.set('bar-object');
  601. component.draft.set({ objectId: 'bar-object', id: 10, cateId: 7, status: 0, orderFlag: 0 });
  602. functions.admin.and.callFake(async (operation: string) => operation === 'barBatch'
  603. ? { action: 'top-global', updated: 1, results: [{ objectId: 'bar-object', id: 10, cateId: 7, status: 0, orderFlag: 2 }] }
  604. : { className: 'GuestBar', page: 1, pageSize: 20, total: 1, results: [] });
  605. await component.applyBarAction('top-global');
  606. expect(functions.admin.calls.first().args).toEqual(['barBatch', { className: 'GuestBar', action: 'top-global', objectIds: ['bar-object'] }]);
  607. expect(component.draft()['orderFlag']).toBe(2);
  608. });
  609. it('moves selected forum posts through the tenant-safe batch operation', async () => {
  610. component.className.set('GuestBar');
  611. component.selectedIds.set(['bar-a', 'bar-b']);
  612. component.targetBarCategoryId.set(8);
  613. functions.admin.and.callFake(async (operation: string) => operation === 'barBatch'
  614. ? { action: 'move', updated: 2, results: [] }
  615. : { className: 'GuestBar', page: 1, pageSize: 20, total: 2, results: [] });
  616. await component.applyBulkBarAction('move');
  617. expect(functions.admin.calls.first().args).toEqual(['barBatch', { className: 'GuestBar', action: 'move', objectIds: ['bar-a', 'bar-b'], cateId: 8 }]);
  618. expect(component.bulkMessage()).toContain('2 条帖子');
  619. });
  620. it('updates content workflow state through the dedicated batch operation', async () => {
  621. component.className.set('CommonModel');
  622. component.draftId.set('content-object');
  623. component.draft.set({ objectId: 'content-object', status: 0, nodeId: 29 });
  624. component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] });
  625. component.selectedContentStatus.set(99);
  626. functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
  627. ? { action: 'status', updated: 1, results: [{ objectId: 'content-object', status: 99, nodeId: 29 }] }
  628. : { className: 'CommonModel', page: 1, pageSize: 20, total: 1, results: [] });
  629. await component.applyContentAction('status');
  630. expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-object'], status: 99 }]);
  631. expect(component.contentStatusValue()).toBe(99);
  632. expect(component.contentActionMessage()).toContain('已审核');
  633. });
  634. it('refreshes legacy content timestamps through the dedicated batch operation', async () => {
  635. component.className.set('CommonModel');
  636. component.draftId.set('content-object');
  637. component.draft.set({ objectId: 'content-object', status: 99, nodeId: 29 });
  638. functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
  639. ? { action: 'refresh', updated: 1, results: [{ objectId: 'content-object', status: 99, nodeId: 29, createTime: '2026-08-18T00:00:00.000Z', upDateTime: '2026-08-18T00:00:00.000Z' }] }
  640. : { className: 'CommonModel', page: 1, pageSize: 20, total: 1, results: [] });
  641. await component.applyContentAction('refresh');
  642. expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'refresh', objectIds: ['content-object'] }]);
  643. expect(component.contentActionMessage()).toContain('时间已刷新');
  644. });
  645. it('checks duplicate content titles through the tenant-safe compatibility operation', async () => {
  646. component.className.set('CommonModel');
  647. component.draft.set({ objectId: 'content-object', title: '同名内容' });
  648. functions.admin.and.resolveTo({ title: '同名内容', total: 2, results: [{ objectId: 'content-object' }, { objectId: 'content-other' }] });
  649. await component.checkDuplicateContentTitle();
  650. expect(functions.admin).toHaveBeenCalledWith('legacyContentUtility', { action: 'duplicate-title', title: '同名内容' });
  651. expect(component.duplicateContentTitles().length).toBe(2);
  652. expect(component.contentActionMessage()).toContain('2 条同名内容');
  653. });
  654. it('selects and clears all records on the current page', () => {
  655. component.className.set('CommonModel');
  656. component.page.set({ className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [{ objectId: 'content-a' }, { objectId: 'content-b' }] });
  657. component.togglePageSelection(true);
  658. expect(component.selectedIds()).toEqual(['content-a', 'content-b']);
  659. expect(component.allPageSelected()).toBeTrue();
  660. component.toggleSelection('content-a', false);
  661. expect(component.selectedIds()).toEqual(['content-b']);
  662. expect(component.allPageSelected()).toBeFalse();
  663. });
  664. it('sends all selected content ids through the bulk workflow operation', async () => {
  665. component.className.set('CommonModel');
  666. component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] });
  667. component.selectedIds.set(['content-a', 'content-b']);
  668. component.selectedContentStatus.set(99);
  669. functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
  670. ? { action: 'status', updated: 2, results: [] }
  671. : { className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [] });
  672. await component.applyBulkContentAction('status');
  673. expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-a', 'content-b'], status: 99 }]);
  674. expect(component.bulkMessage()).toContain('2 条内容');
  675. expect(component.selectedIds()).toEqual([]);
  676. });
  677. it('recovers all selected nodes through the dedicated batch operation', async () => {
  678. component.className.set('Node');
  679. component.schema.set({ className: 'Node', label: '栏目节点', writable: true, supportsSoftDelete: false, fields: [] });
  680. component.selectedIds.set(['node-a', 'node-b']);
  681. functions.admin.and.callFake(async (operation: string) => operation === 'nodeBatch'
  682. ? { action: 'recover', updated: 2, results: [] }
  683. : { className: 'Node', page: 1, pageSize: 20, total: 2, results: [] });
  684. await component.applyBulkNodeAction('recover');
  685. expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'recover', objectIds: ['node-a', 'node-b'] }]);
  686. expect(component.bulkMessage()).toContain('2 个栏目');
  687. });
  688. it('loads and opens the virtual global content-tag resource through dedicated cloud operations', async () => {
  689. component.className.set('ContentTagKey');
  690. functions.admin.and.callFake(async (operation: string) => operation === 'schema'
  691. ? { className: 'ContentTagKey', label: '内容标签词库', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }
  692. : operation === 'contentTagKeys'
  693. ? { className: 'ContentTagKey', page: 1, pageSize: 20, total: 1, results: [{ objectId: 'global-content-tags', count: 2, content: '教育|英语' }] }
  694. : { objectId: 'global-content-tags', count: 2, content: '教育|英语', tags: ['教育', '英语'] });
  695. await component.load(1, true);
  696. await component.openEdit({ objectId: 'global-content-tags' });
  697. expect(functions.admin).toHaveBeenCalledWith('contentTagKeys', { className: 'ContentTagKey', page: 1, pageSize: 20, search: '' });
  698. expect(functions.admin).toHaveBeenCalledWith('getContentTagKeys', { className: 'ContentTagKey', objectId: 'global-content-tags' });
  699. expect(component.tagKeyContent()).toBe('教育|英语');
  700. });
  701. it('saves global content tags through Parse Config instead of generic CRUD', async () => {
  702. component.className.set('ContentTagKey');
  703. component.schema.set({ className: 'ContentTagKey', label: '内容标签词库', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  704. component.draftId.set('global-content-tags');
  705. component.draft.set({ objectId: 'global-content-tags', count: 2, content: '教育|英语', tags: ['教育', '英语'] });
  706. component.tagKeyContent.set('教育 | 英语\n阅读');
  707. functions.admin.and.callFake(async (operation: string) => operation === 'saveContentTagKeys'
  708. ? { objectId: 'global-content-tags', count: 3, content: '教育|英语|阅读', tags: ['教育', '英语', '阅读'] }
  709. : { className: 'ContentTagKey', page: 1, pageSize: 20, total: 1, results: [] });
  710. await component.save();
  711. expect(functions.admin.calls.first().args).toEqual(['saveContentTagKeys', {
  712. className: 'ContentTagKey', objectId: 'global-content-tags', fields: { objectId: 'global-content-tags', count: 2, content: '教育|英语', tags: ['教育', '英语'] }, content: '教育 | 英语\n阅读',
  713. }]);
  714. });
  715. it('updates an existing user level through its protected operation', async () => {
  716. component.className.set('UserLevel');
  717. component.schema.set({ className: 'UserLevel', label: '积分等级', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
  718. component.draftId.set('level-object');
  719. component.draft.set({ objectId: 'level-object', id: 1, alias: '白银会员', pointRate: 100, image: '/level.png', remark: '等级备注' });
  720. functions.admin.and.callFake(async (operation: string) => operation === 'saveUserLevel'
  721. ? { objectId: 'level-object', id: 1, alias: '白银会员', pointRate: 100 }
  722. : { className: 'UserLevel', page: 1, pageSize: 20, total: 1, results: [] });
  723. await component.save();
  724. expect(functions.admin.calls.first().args).toEqual(['saveUserLevel', {
  725. className: 'UserLevel', objectId: 'level-object', fields: { objectId: 'level-object', id: 1, alias: '白银会员', pointRate: 100, image: '/level.png', remark: '等级备注' },
  726. }]);
  727. });
  728. it('loads the selected normalized money ledger with date filters', async () => {
  729. component.className.set('UserMoneyLog');
  730. component.schema.set({ className: 'UserMoneyLog', label: '资金积分流水', writable: false, creatable: false, supportsSoftDelete: false, fields: [] });
  731. component.filterUserMoneyType.set(4);
  732. component.filterUserMoneyStartDate.set('2026-08-01');
  733. component.filterUserMoneyEndDate.set('2026-08-18');
  734. functions.admin.and.resolveTo({ className: 'UserMoneyLog', page: 1, pageSize: 20, total: 1, results: [] });
  735. await component.load(1);
  736. expect(functions.admin).toHaveBeenCalledWith('userMoneyLogs', { className: 'UserMoneyLog', page: 1, pageSize: 20, search: '', type: 4, startDate: '2026-08-01', endDate: '2026-08-18' });
  737. });
  738. it('deletes selected user levels through the dedicated batch operation', async () => {
  739. spyOn(window, 'confirm').and.returnValue(true);
  740. component.className.set('UserLevel');
  741. component.selectedIds.set(['level-a', 'level-b']);
  742. functions.admin.and.callFake(async (operation: string) => operation === 'userLevelBatch'
  743. ? { action: 'delete', updated: 2, results: [] }
  744. : { className: 'UserLevel', page: 1, pageSize: 20, total: 0, results: [] });
  745. await component.applyBulkUserLevelDelete();
  746. expect(functions.admin.calls.first().args).toEqual(['userLevelBatch', { className: 'UserLevel', action: 'delete', objectIds: ['level-a', 'level-b'] }]);
  747. expect(component.bulkMessage()).toContain('2 个积分等级');
  748. });
  749. it('loads encyclopedia entries through status and recommendation filters', async () => {
  750. component.className.set('Baike');
  751. component.schema.set({ className: 'Baike', label: '百科审核', writable: false, creatable: false, supportsSoftDelete: false, fields: [] });
  752. component.filterBaikeStatus.set(1);
  753. component.filterBaikeElite.set(0);
  754. functions.admin.and.resolveTo({ className: 'Baike', page: 1, pageSize: 20, total: 1, results: [] });
  755. await component.load(1);
  756. expect(functions.admin).toHaveBeenCalledWith('baikeEntries', { className: 'Baike', page: 1, pageSize: 20, search: '', status: 1, elite: 0 });
  757. });
  758. it('recommends selected encyclopedia entries through the dedicated workflow', async () => {
  759. component.className.set('Baike');
  760. component.selectedIds.set(['baike-a', 'baike-b']);
  761. functions.admin.and.callFake(async (operation: string) => operation === 'baikeBatch'
  762. ? { action: 'elite', updated: 2, results: [] }
  763. : { className: 'Baike', page: 1, pageSize: 20, total: 2, results: [] });
  764. await component.applyBulkBaikeAction('elite');
  765. expect(functions.admin.calls.first().args).toEqual(['baikeBatch', { className: 'Baike', action: 'elite', objectIds: ['baike-a', 'baike-b'] }]);
  766. expect(component.bulkMessage()).toContain('2 个百科词条');
  767. });
  768. it('loads only CRM customer-type attributes through the dedicated operation', async () => {
  769. component.className.set('CRMSAttr');
  770. component.schema.set({ className: 'CRMSAttr', label: 'CRM 客户类型', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
  771. component.search.set('公司'); functions.admin.and.resolveTo({ className: 'CRMSAttr', page: 1, pageSize: 20, total: 1, results: [] });
  772. await component.load(1);
  773. expect(functions.admin).toHaveBeenCalledWith('crmClientTypes', { className: 'CRMSAttr', page: 1, pageSize: 20, search: '公司' });
  774. });
  775. it('saves CRM customer types through the protected lifecycle operation', async () => {
  776. component.className.set('CRMSAttr'); component.draft.set({ value: '渠道客户', remark: '渠道来源' });
  777. functions.admin.and.callFake(async (operation: string) => operation === 'saveCrmClientType' ? { objectId: 'crm-type', id: '4', value: '渠道客户' } : { className: 'CRMSAttr', page: 1, pageSize: 20, total: 4, results: [] });
  778. await component.save();
  779. expect(functions.admin.calls.first().args).toEqual(['saveCrmClientType', { className: 'CRMSAttr', objectId: undefined, fields: { value: '渠道客户', remark: '渠道来源' } }]);
  780. });
  781. it('deletes selected CRM customer types through the dedicated batch operation', async () => {
  782. spyOn(window, 'confirm').and.returnValue(true); component.className.set('CRMSAttr'); component.selectedIds.set(['crm-a', 'crm-b']);
  783. functions.admin.and.callFake(async (operation: string) => operation === 'crmClientTypeBatch' ? { action: 'delete', updated: 2 } : { className: 'CRMSAttr', page: 1, pageSize: 20, total: 0, results: [] });
  784. await component.applyBulkCrmClientTypeDelete();
  785. expect(functions.admin.calls.first().args).toEqual(['crmClientTypeBatch', { className: 'CRMSAttr', action: 'delete', objectIds: ['crm-a', 'crm-b'] }]);
  786. expect(component.bulkMessage()).toContain('2 个客户类型');
  787. });
  788. });