test: add M1 broadcast acceptance reproducers

This commit is contained in:
2026-07-23 11:27:53 +08:00
parent f023417aed
commit cd94f4fe9f
3 changed files with 210 additions and 0 deletions
@@ -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<Map<String, Object>> targetCandidates = List.of();
private String targetCandidateSql;
private Object[] targetCandidateArgs;
private String messageListSql;
private final List<String> updateSqls = new ArrayList<>();
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.contains("from sys_user u")) return requiredType.cast(1L);
return requiredType.cast(1L);
}
@Override
public <T> List<T> query(String sql, RowMapper<T> 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> T map(RowMapper<T> rowMapper, Map<String, Object> values) {
try {
return rowMapper.mapRow(resultSet(values), 0);
} catch (SQLException ex) {
throw new AssertionError(ex);
}
}
private static ResultSet resultSet(Map<String, Object> 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);
});
}
}
}
@@ -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;');
});
});
+24
View File
@@ -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<BroadcastMessageScope>\('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/);
});