From cd94f4fe9fcb6e0bc4e78d37749fbe5881a90b3f Mon Sep 17 00:00:00 2001 From: let5sne Date: Thu, 23 Jul 2026 11:27:53 +0800 Subject: [PATCH] test: add M1 broadcast acceptance reproducers --- .../broadcast/AihrBroadcastM1ServiceTest.java | 161 ++++++++++++++++++ .../src/utils/broadcast-m1-contract.test.ts | 25 +++ mobile-uni/tests/broadcast-m1.test.mjs | 24 +++ 3 files changed, 210 insertions(+) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java create mode 100644 frontend/src/utils/broadcast-m1-contract.test.ts create mode 100644 mobile-uni/tests/broadcast-m1.test.mjs diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java new file mode 100644 index 00000000..c864f4eb --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastM1ServiceTest.java @@ -0,0 +1,161 @@ +package org.dromara.aihr.broadcast; + +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetRequest; +import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; +import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.KeyHolder; + +import java.lang.reflect.Proxy; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrBroadcastM1ServiceTest { + + @Test + void targetedPublicationFreezesActiveOrganizationRecipientsAndKeepsUnmatchedSubjectsAuditable() { + TargetingJdbcTemplate jdbcTemplate = new TargetingJdbcTemplate(); + jdbcTemplate.targetCandidates = List.of( + Map.of("subject_ref", "EMP-1003", "user_id", 31L), + Map.of("subject_ref", "EMP-1004") + ); + AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER); + + PublishResponse response = service.publish(new PublishRequest( + "m1-targeted-1", "夏季服务标准", "请项目客服人员在今日完成学习。", "tenant-a", true, + new BroadcastTargetRequest(List.of("PRJ-FCW"), List.of("物业管家"), List.of()) + )); + + assertEquals(42L, response.id()); + assertEquals(1L, response.matchedRecipientCount()); + assertEquals(1L, response.unmatchedTargetCount()); + assertTrue(jdbcTemplate.targetCandidateSql.contains("from aihr_org_snapshot o")); + assertTrue(jdbcTemplate.targetCandidateSql.contains("binary o.tenant_id = binary u.tenant_id")); + assertTrue(jdbcTemplate.targetCandidateSql.contains("o.employment_status = 'active'")); + assertTrue(jdbcTemplate.targetCandidateSql.contains("o.project_code in (?)")); + assertTrue(jdbcTemplate.targetCandidateSql.contains("o.position_name in (?)")); + assertArrayEquals(new Object[]{"tenant-a", "PRJ-FCW", "物业管家"}, jdbcTemplate.targetCandidateArgs); + assertTrue(jdbcTemplate.updateSqls.stream().anyMatch(sql -> sql.contains("aihr_broadcast_target_rule"))); + assertTrue(jdbcTemplate.updateSqls.stream().anyMatch(sql -> sql.contains("aihr_broadcast_target_recipient"))); + } + + @Test + void requiredUnreadScopeUsesRecipientSnapshotsWhileAllScopeKeepsPublicMessagesVisible() { + TargetingJdbcTemplate jdbcTemplate = new TargetingJdbcTemplate(); + AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER); + + BroadcastListResponse requiredUnread = service.messages(1, 20, "REQUIRED_UNREAD"); + + assertEquals(1, requiredUnread.rows().size()); + assertTrue(requiredUnread.rows().get(0).requiredRead()); + assertTrue(requiredUnread.rows().get(0).targeted()); + assertFalse(requiredUnread.rows().get(0).read()); + assertTrue(jdbcTemplate.messageListSql.contains("m.required_read = 1")); + assertTrue(jdbcTemplate.messageListSql.contains("aihr_broadcast_target_recipient")); + assertTrue(jdbcTemplate.messageListSql.contains("tr.user_id is not null")); + + service.messages(1, 20, "ALL"); + + assertFalse(jdbcTemplate.messageListSql.contains("tr.user_id is not null")); + assertTrue(jdbcTemplate.messageListSql.contains("m.status = 'PUBLISHED'")); + } + + private static final AihrKnowledgePrincipalResolver RESOLVER = new AihrKnowledgePrincipalResolver(null) { + @Override + public AihrKnowledgePrincipal current() { + return new AihrKnowledgePrincipal( + "tenant-a", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile"); + } + }; + + private static final class TargetingJdbcTemplate extends JdbcTemplate { + private List> targetCandidates = List.of(); + private String targetCandidateSql; + private Object[] targetCandidateArgs; + private String messageListSql; + private final List updateSqls = new ArrayList<>(); + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + if (sql.contains("from sys_user u")) return requiredType.cast(1L); + return requiredType.cast(1L); + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + if (sql.contains("publish_request_key")) return List.of(); + if (sql.contains("from aihr_org_snapshot o")) { + targetCandidateSql = sql; + targetCandidateArgs = args; + return targetCandidates.stream().map(values -> map(rowMapper, values)).toList(); + } + if (sql.contains("from aihr_broadcast_message m")) { + messageListSql = sql; + return List.of(map(rowMapper, Map.of( + "id", 42L, + "title", "夏季服务标准", + "published_time", Timestamp.valueOf("2026-07-23 09:00:00"), + "read_flag", false, + "required_read", true, + "targeted_flag", true, + "target_reason", "项目、岗位" + ))); + } + return List.of(); + } + + @Override + public int update(String sql, Object... args) { + updateSqls.add(sql); + return 1; + } + + @Override + public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) { + generatedKeyHolder.getKeyList().add(Map.of("id", 42L)); + return 1; + } + + private static T map(RowMapper rowMapper, Map values) { + try { + return rowMapper.mapRow(resultSet(values), 0); + } catch (SQLException ex) { + throw new AssertionError(ex); + } + } + + private static ResultSet resultSet(Map values) { + return (ResultSet) Proxy.newProxyInstance( + TargetingJdbcTemplate.class.getClassLoader(), new Class[]{ResultSet.class}, (proxy, method, args) -> { + String name = method.getName(); + Object value = args == null || args.length == 0 ? null : values.get(args[0]); + if ("getString".equals(name)) return value == null ? null : value.toString(); + if ("getLong".equals(name)) return value == null ? 0L : ((Number) value).longValue(); + if ("getBoolean".equals(name)) return value instanceof Boolean bool && bool; + if ("getTimestamp".equals(name)) return value; + if ("getObject".equals(name)) return value; + if ("wasNull".equals(name)) return value == null; + if ("toString".equals(name)) return values.toString(); + throw new UnsupportedOperationException(name); + }); + } + } +} diff --git a/frontend/src/utils/broadcast-m1-contract.test.ts b/frontend/src/utils/broadcast-m1-contract.test.ts new file mode 100644 index 00000000..6e493a8d --- /dev/null +++ b/frontend/src/utils/broadcast-m1-contract.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); + +describe('M1 银城大喇叭发布契约', () => { + it('管理端携带必读和定向范围,并使用受控目标选项', () => { + const api = read('src/api/aihr/broadcast.ts'); + const page = read('src/views/content/broadcast.vue'); + const pending = read('src/utils/broadcast-pending.ts'); + + expect(api).toContain('requiredRead: boolean;'); + expect(api).toContain('targets: BroadcastTargetRequest;'); + expect(api).toContain("'/api/aihr/broadcast/admin/target-options'"); + expect(page).toContain('v-model="form.requiredRead"'); + expect(page).toContain('v-model="form.targets.projectCodes"'); + expect(page).toContain('v-model="form.targets.positionNames"'); + expect(page).toContain('v-model="form.targets.positionLevels"'); + expect(page).toContain('loadBroadcastTargetOptions'); + expect(page).toContain('matchedRecipientCount'); + expect(pending).toContain('requiredRead: boolean;'); + expect(pending).toContain('targets: BroadcastTargetRequest;'); + }); +}); diff --git a/mobile-uni/tests/broadcast-m1.test.mjs b/mobile-uni/tests/broadcast-m1.test.mjs new file mode 100644 index 00000000..60c4eec8 --- /dev/null +++ b/mobile-uni/tests/broadcast-m1.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8'); + +test('M1 员工端可筛选与我有关和必读未读消息,并保留公开频道', async () => { + const [service, list, detail] = await Promise.all([ + read('../src/services/broadcast.ts'), + read('../src/pages/user/broadcast/index.vue'), + read('../src/pages/user/broadcast/detail.vue') + ]); + + assert.match(service, /export type BroadcastMessageScope = 'ALL' \| 'RELATED' \| 'REQUIRED_UNREAD';/); + assert.match(service, /scope=\$\{scope\}/); + assert.match(list, /const scope = ref\('ALL'\)/); + assert.match(list, /scopeOptions/); + assert.match(list, /@click="changeScope\(option\.value\)"/); + assert.match(list, /listBroadcastMessages\(1, pageSize, scope\.value\)/); + assert.match(list, /item\.requiredRead/); + assert.match(list, /item\.targeted/); + assert.match(detail, /item\.requiredRead/); + assert.match(detail, /item\.targeted/); +});