mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(dev): stabilize local env and phase3 flows
This commit is contained in:
parent
7beda7cf54
commit
e63531ca3d
25 changed files with 681 additions and 70 deletions
21
Makefile
21
Makefile
|
|
@ -14,9 +14,7 @@ help: ## 显示帮助
|
|||
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
dev: ## 启动本地开发环境(仅依赖服务)
|
||||
docker compose up -d
|
||||
@echo "Waiting for services to be healthy..."
|
||||
@sleep 5
|
||||
docker compose up -d --wait --remove-orphans
|
||||
@echo "Services ready."
|
||||
@echo "Start backend with: make dev-server"
|
||||
@echo "Start frontend with: make dev-web"
|
||||
|
|
@ -32,7 +30,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
|||
echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \
|
||||
else \
|
||||
echo "Starting backend..."; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \
|
||||
fi
|
||||
@if [ -f $(DEV_WEB_PID) ] && kill -0 "$$(cat $(DEV_WEB_PID))" 2>/dev/null; then \
|
||||
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
|
||||
|
|
@ -71,15 +69,18 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
|||
@echo "Local environment is ready:"
|
||||
@echo " Web UI: $(DEV_WEB_URL)"
|
||||
@echo " Backend: $(DEV_API_URL)"
|
||||
@echo "Mock auth users:"
|
||||
@echo " local-user -> X-Mock-User-Id: local-user"
|
||||
@echo " local-admin -> X-Mock-User-Id: local-admin"
|
||||
@echo "Logs:"
|
||||
@echo " Backend: $(DEV_SERVER_LOG)"
|
||||
@echo " Frontend: $(DEV_WEB_LOG)"
|
||||
|
||||
dev-server: ## 启动后端开发服务器
|
||||
cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local
|
||||
cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local'
|
||||
|
||||
dev-down: ## 停止本地开发环境
|
||||
docker compose down
|
||||
docker compose down --remove-orphans
|
||||
|
||||
dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
|
|
@ -89,7 +90,7 @@ dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端)
|
|||
dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID)
|
||||
docker compose down -v
|
||||
docker compose down -v --remove-orphans
|
||||
rm -rf $(DEV_DIR)
|
||||
@$(MAKE) dev-all
|
||||
|
||||
|
|
@ -127,8 +128,6 @@ lint-web: ## 前端代码检查
|
|||
cd web && pnpm run lint
|
||||
|
||||
db-reset: ## 重置数据库
|
||||
docker compose down -v
|
||||
docker compose up -d postgres
|
||||
@echo "Waiting for postgres..."
|
||||
@sleep 3
|
||||
docker compose down -v --remove-orphans
|
||||
docker compose up -d --wait --remove-orphans postgres
|
||||
cd server && ./mvnw flyway:migrate -pl skillhub-app
|
||||
|
|
|
|||
138
scripts/dev_process.py
Normal file
138
scripts/dev_process.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_running(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_pid(pid_file: Path) -> int | None:
|
||||
if not pid_file.exists():
|
||||
return None
|
||||
content = pid_file.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
return int(content)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def write_pid(pid_file: Path, pid: int) -> None:
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_file.write_text(f"{pid}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def start_process(args: argparse.Namespace) -> int:
|
||||
pid_file = Path(args.pid_file)
|
||||
log_file = Path(args.log_file)
|
||||
cwd = Path(args.cwd)
|
||||
existing_pid = read_pid(pid_file)
|
||||
if existing_pid and is_running(existing_pid):
|
||||
print(existing_pid)
|
||||
return 0
|
||||
|
||||
pid_file.unlink(missing_ok=True)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
command = list(args.command)
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
|
||||
with log_file.open("ab") as log_handle, open(os.devnull, "rb") as devnull:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
stdin=devnull,
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
write_pid(pid_file, process.pid)
|
||||
print(process.pid)
|
||||
return 0
|
||||
|
||||
|
||||
def stop_process(args: argparse.Namespace) -> int:
|
||||
pid_file = Path(args.pid_file)
|
||||
pid = read_pid(pid_file)
|
||||
if not pid:
|
||||
return 0
|
||||
|
||||
if not is_running(pid):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
deadline = time.time() + args.timeout
|
||||
while time.time() < deadline:
|
||||
if not is_running(pid):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 0
|
||||
time.sleep(0.2)
|
||||
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
def status_process(args: argparse.Namespace) -> int:
|
||||
pid_file = Path(args.pid_file)
|
||||
pid = read_pid(pid_file)
|
||||
if not pid:
|
||||
return 1
|
||||
if not is_running(pid):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 1
|
||||
print(pid)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Manage detached dev processes.")
|
||||
subparsers = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
start_parser = subparsers.add_parser("start")
|
||||
start_parser.add_argument("--pid-file", required=True)
|
||||
start_parser.add_argument("--log-file", required=True)
|
||||
start_parser.add_argument("--cwd", required=True)
|
||||
start_parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
|
||||
stop_parser = subparsers.add_parser("stop")
|
||||
stop_parser.add_argument("--pid-file", required=True)
|
||||
stop_parser.add_argument("--timeout", type=float, default=10.0)
|
||||
|
||||
status_parser = subparsers.add_parser("status")
|
||||
status_parser.add_argument("--pid-file", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action == "start":
|
||||
if not args.command:
|
||||
parser.error("start requires a command")
|
||||
return start_process(args)
|
||||
if args.action == "stop":
|
||||
return stop_process(args)
|
||||
if args.action == "status":
|
||||
return status_process(args)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.Role;
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Component
|
||||
@Profile("local")
|
||||
public class LocalDevDataInitializer implements ApplicationRunner {
|
||||
|
||||
public static final String LOCAL_USER_ID = "local-user";
|
||||
public static final String LOCAL_ADMIN_ID = "local-admin";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LocalDevDataInitializer.class);
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
|
||||
public LocalDevDataInitializer(UserAccountRepository userAccountRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
RoleRepository roleRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
UserAccount localUser = ensureUser(
|
||||
LOCAL_USER_ID,
|
||||
"Local Developer",
|
||||
"local-user@example.test"
|
||||
);
|
||||
UserAccount localAdmin = ensureUser(
|
||||
LOCAL_ADMIN_ID,
|
||||
"Local Admin",
|
||||
"local-admin@example.test"
|
||||
);
|
||||
|
||||
Namespace globalNamespace = namespaceRepository.findBySlug("global")
|
||||
.orElseThrow(() -> new IllegalStateException("Missing built-in global namespace"));
|
||||
|
||||
ensureMembership(globalNamespace.getId(), localUser.getId(), NamespaceRole.OWNER);
|
||||
ensureMembership(globalNamespace.getId(), localAdmin.getId(), NamespaceRole.OWNER);
|
||||
ensureRole(localAdmin.getId(), "SUPER_ADMIN");
|
||||
|
||||
log.info("Local dev accounts ready: {} / {}", LOCAL_USER_ID, LOCAL_ADMIN_ID);
|
||||
}
|
||||
|
||||
private UserAccount ensureUser(String userId, String displayName, String email) {
|
||||
return userAccountRepository.findById(userId)
|
||||
.map(existing -> {
|
||||
existing.setDisplayName(displayName);
|
||||
existing.setEmail(email);
|
||||
existing.setStatus(UserStatus.ACTIVE);
|
||||
return userAccountRepository.save(existing);
|
||||
})
|
||||
.orElseGet(() -> userAccountRepository.save(
|
||||
new UserAccount(userId, displayName, email, null)
|
||||
));
|
||||
}
|
||||
|
||||
private void ensureMembership(Long namespaceId, String userId, NamespaceRole role) {
|
||||
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
|
||||
.orElseGet(() -> new NamespaceMember(namespaceId, userId, role));
|
||||
if (member.getRole() != role) {
|
||||
member.setRole(role);
|
||||
}
|
||||
namespaceMemberRepository.save(member);
|
||||
}
|
||||
|
||||
private void ensureRole(String userId, String roleCode) {
|
||||
boolean exists = userRoleBindingRepository.findByUserId(userId).stream()
|
||||
.map(binding -> binding.getRole().getCode())
|
||||
.anyMatch(roleCode::equals);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
Role role = roleRepository.findByCode(roleCode)
|
||||
.orElseThrow(() -> new IllegalStateException("Missing built-in role: " + roleCode));
|
||||
userRoleBindingRepository.save(new UserRoleBinding(userId, role));
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ public class PromotionController extends BaseApiController {
|
|||
PromotionRequest promotion = promotionService.submitPromotion(
|
||||
request.sourceSkillId(), request.sourceVersionId(),
|
||||
request.targetNamespaceId(), userId);
|
||||
return ok("response.success.create", toResponse(promotion));
|
||||
return ok("response.success.created", toResponse(promotion));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ public class ReviewController extends BaseApiController {
|
|||
.orElseThrow();
|
||||
Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow();
|
||||
ReviewTask task = reviewService.submitReview(request.skillVersionId(), skill.getNamespaceId(), userId);
|
||||
return ok("response.success.create", toResponse(task));
|
||||
return ok("response.success.created", toResponse(task));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
package com.iflytek.skillhub.exception;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
|
@ -32,13 +32,6 @@ public class GlobalExceptionHandler {
|
|||
apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthFlowException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex) {
|
||||
HttpStatus status = ex.getStatus();
|
||||
return ResponseEntity.status(status).body(
|
||||
apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainBadRequestException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
|
|
@ -51,6 +44,12 @@ public class GlobalExceptionHandler {
|
|||
apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainNotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainNotFound(DomainNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
|
||||
apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String msg = ex.getBindingResult().getFieldErrors().stream()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ error.skill.publish.package.invalid=Package validation failed: {0}
|
|||
error.skill.publish.skillMd.notFound=SKILL.md not found
|
||||
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
|
||||
error.skill.notFound=Skill not found: {0}
|
||||
skill.not_found=Skill not found: {0}
|
||||
skill_version.not_found=Skill version not found: {0}
|
||||
namespace.not_found=Namespace not found: {0}
|
||||
promotion.not_found=Promotion request not found: {0}
|
||||
review_task.not_found=Review task not found: {0}
|
||||
review_task.not_found_for_version=Review task not found for skill version: {0}
|
||||
error.skill.access.denied=Access denied to skill: {0}
|
||||
error.skill.status.notActive=Skill is not active
|
||||
error.skill.version.exists=Version already exists: {0}
|
||||
|
|
@ -71,15 +77,3 @@ error.deviceAuth.userCode.invalid=Invalid or expired user code
|
|||
error.deviceAuth.deviceCode.expired=Device code expired
|
||||
error.deviceAuth.deviceCode.invalid=Device code expired or invalid
|
||||
error.deviceAuth.deviceCode.used=Device code has already been used
|
||||
error.auth.local.username.invalid=Username must be 3-64 characters and contain only letters, numbers, or underscores
|
||||
error.auth.local.username.exists=Username already exists
|
||||
error.auth.local.email.exists=Email already exists
|
||||
error.auth.local.invalidCredentials=Invalid username or password
|
||||
error.auth.local.accountDisabled=Account has been disabled
|
||||
error.auth.local.accountPending=Account is pending approval
|
||||
error.auth.local.accountMerged=Account has been merged into another account
|
||||
error.auth.local.locked=Account is locked. Try again in {0} minute(s)
|
||||
error.auth.local.notEnabled=Password login is not enabled for this account
|
||||
error.auth.local.password.tooShort=Password must be at least 8 characters
|
||||
error.auth.local.password.tooLong=Password must not exceed 128 characters
|
||||
error.auth.local.password.tooWeak=Password must contain at least three character types
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ error.skill.publish.package.invalid=技能包校验失败:{0}
|
|||
error.skill.publish.skillMd.notFound=未找到 SKILL.md
|
||||
error.skill.publish.precheck.failed=预发布校验失败:{0}
|
||||
error.skill.notFound=未找到技能:{0}
|
||||
skill.not_found=未找到技能:{0}
|
||||
skill_version.not_found=未找到技能版本:{0}
|
||||
namespace.not_found=未找到命名空间:{0}
|
||||
promotion.not_found=未找到推广申请:{0}
|
||||
review_task.not_found=未找到审核任务:{0}
|
||||
review_task.not_found_for_version=未找到该技能版本对应的审核任务:{0}
|
||||
error.skill.access.denied=没有权限访问技能:{0}
|
||||
error.skill.status.notActive=技能未处于 ACTIVE 状态
|
||||
error.skill.version.exists=版本已存在:{0}
|
||||
|
|
@ -71,15 +77,3 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码
|
|||
error.deviceAuth.deviceCode.expired=设备验证码已过期
|
||||
error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期
|
||||
error.deviceAuth.deviceCode.used=设备验证码已被使用
|
||||
error.auth.local.username.invalid=用户名长度必须为 3 到 64 个字符,且只能包含字母、数字或下划线
|
||||
error.auth.local.username.exists=用户名已存在
|
||||
error.auth.local.email.exists=邮箱已存在
|
||||
error.auth.local.invalidCredentials=用户名或密码错误
|
||||
error.auth.local.accountDisabled=账号已被禁用
|
||||
error.auth.local.accountPending=账号仍在审核中
|
||||
error.auth.local.accountMerged=账号已合并到其他账号
|
||||
error.auth.local.locked=账号已锁定,请 {0} 分钟后重试
|
||||
error.auth.local.notEnabled=当前账号未启用密码登录
|
||||
error.auth.local.password.tooShort=密码长度至少为 8 位
|
||||
error.auth.local.password.tooLong=密码长度不能超过 128 位
|
||||
error.auth.local.password.tooWeak=密码至少需要包含三种字符类型
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.Role;
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.DefaultApplicationArguments;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LocalDevDataInitializerTest {
|
||||
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private NamespaceMemberRepository namespaceMemberRepository;
|
||||
@Mock private RoleRepository roleRepository;
|
||||
@Mock private UserRoleBindingRepository userRoleBindingRepository;
|
||||
|
||||
private LocalDevDataInitializer initializer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
initializer = new LocalDevDataInitializer(
|
||||
userAccountRepository,
|
||||
namespaceRepository,
|
||||
namespaceMemberRepository,
|
||||
roleRepository,
|
||||
userRoleBindingRepository
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSeedLocalUsersGlobalMembershipAndSuperAdminRole() throws Exception {
|
||||
Namespace global = new Namespace("global", "Global", "system");
|
||||
setField(global, "id", 1L);
|
||||
|
||||
Role superAdminRole = new Role();
|
||||
setField(superAdminRole, "id", 1L);
|
||||
setField(superAdminRole, "code", "SUPER_ADMIN");
|
||||
|
||||
when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_USER_ID)).thenReturn(Optional.empty());
|
||||
when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(Optional.empty());
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(anyLong(), any())).thenReturn(Optional.empty());
|
||||
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole));
|
||||
when(userRoleBindingRepository.findByUserId(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(List.of());
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
|
||||
verify(userAccountRepository, times(2)).save(userCaptor.capture());
|
||||
List<UserAccount> savedUsers = userCaptor.getAllValues();
|
||||
assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_USER_ID.equals(user.getId())));
|
||||
assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_ADMIN_ID.equals(user.getId())));
|
||||
|
||||
ArgumentCaptor<NamespaceMember> memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class);
|
||||
verify(namespaceMemberRepository, times(2)).save(memberCaptor.capture());
|
||||
assertEquals(
|
||||
List.of(LocalDevDataInitializer.LOCAL_USER_ID, LocalDevDataInitializer.LOCAL_ADMIN_ID),
|
||||
memberCaptor.getAllValues().stream().map(NamespaceMember::getUserId).toList()
|
||||
);
|
||||
assertTrue(memberCaptor.getAllValues().stream().allMatch(member -> member.getRole() == NamespaceRole.OWNER));
|
||||
|
||||
ArgumentCaptor<UserRoleBinding> roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class);
|
||||
verify(userRoleBindingRepository).save(roleBindingCaptor.capture());
|
||||
assertEquals(LocalDevDataInitializer.LOCAL_ADMIN_ID, roleBindingCaptor.getValue().getUserId());
|
||||
assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode());
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillRatingService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -107,4 +108,33 @@ class SkillRatingControllerTest {
|
|||
.content("{\"score\": 4}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_user_rating_missing_skill_returns_404_envelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"tester",
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("SUPER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
|
||||
when(skillRatingService.getUserRating(eq(999L), eq("user-42")))
|
||||
.thenThrow(new DomainNotFoundException("skill.not_found", 999L));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/999/rating")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(404))
|
||||
.andExpect(jsonPath("$.msg").value("Skill not found: 999"))
|
||||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -126,4 +127,33 @@ class SkillStarControllerTest {
|
|||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void check_starred_missing_skill_returns_404_envelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"tester",
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("SUPER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
|
||||
when(skillStarService.isStarred(eq(999L), eq("user-42")))
|
||||
.thenThrow(new DomainNotFoundException("skill.not_found", 999L));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/999/star")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(404))
|
||||
.andExpect(jsonPath("$.msg").value("Skill not found: 999"))
|
||||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package com.iflytek.skillhub.auth.device;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -20,12 +23,25 @@ public class DeviceAuthService {
|
|||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final String verificationUri;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiTokenService apiTokenService;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
@Autowired
|
||||
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
|
||||
ApiTokenService apiTokenService,
|
||||
@Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) {
|
||||
this(redisTemplate, apiTokenService, verificationUri, new ObjectMapper());
|
||||
}
|
||||
|
||||
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
|
||||
ApiTokenService apiTokenService,
|
||||
String verificationUri,
|
||||
ObjectMapper objectMapper) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.verificationUri = verificationUri;
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiTokenService = apiTokenService;
|
||||
}
|
||||
|
||||
public DeviceCodeResponse generateDeviceCode() {
|
||||
|
|
@ -48,7 +64,7 @@ public class DeviceAuthService {
|
|||
throw new DomainBadRequestException("error.deviceAuth.userCode.invalid");
|
||||
}
|
||||
|
||||
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
DeviceCodeData data = readDeviceCodeData(deviceCode);
|
||||
if (data == null) {
|
||||
throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired");
|
||||
}
|
||||
|
|
@ -60,7 +76,7 @@ public class DeviceAuthService {
|
|||
}
|
||||
|
||||
public DeviceTokenResponse pollToken(String deviceCode) {
|
||||
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
DeviceCodeData data = readDeviceCodeData(deviceCode);
|
||||
|
||||
if (data == null) {
|
||||
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
|
||||
|
|
@ -72,7 +88,9 @@ public class DeviceAuthService {
|
|||
data.setStatus(DeviceCodeStatus.USED);
|
||||
redisTemplate.opsForValue().set(
|
||||
DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES);
|
||||
yield DeviceTokenResponse.success(null);
|
||||
String token = apiTokenService.createToken(
|
||||
data.getUserId(), "device-auth", "[]").rawToken();
|
||||
yield DeviceTokenResponse.success(token);
|
||||
}
|
||||
case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used");
|
||||
};
|
||||
|
|
@ -92,4 +110,15 @@ public class DeviceAuthService {
|
|||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
private DeviceCodeData readDeviceCodeData(String deviceCode) {
|
||||
Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
if (raw instanceof DeviceCodeData data) {
|
||||
return data;
|
||||
}
|
||||
return objectMapper.convertValue(raw, DeviceCodeData.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.device;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
|
@ -10,6 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
|
@ -26,12 +30,15 @@ class DeviceAuthServiceTest {
|
|||
@Mock
|
||||
private ValueOperations<String, Object> valueOperations;
|
||||
|
||||
@Mock
|
||||
private ApiTokenService apiTokenService;
|
||||
|
||||
private DeviceAuthService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
service = new DeviceAuthService(redisTemplate, "https://skillhub.example.com/device");
|
||||
service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -103,4 +110,56 @@ class DeviceAuthServiceTest {
|
|||
assertThat(data.getUserId()).isEqualTo("42");
|
||||
verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeDeviceCode_accepts_linked_hash_map_from_redis_serializer() {
|
||||
Map<String, Object> redisValue = new HashMap<>();
|
||||
redisValue.put("deviceCode", "device123");
|
||||
redisValue.put("userCode", "ABCD-1234");
|
||||
redisValue.put("status", "PENDING");
|
||||
redisValue.put("userId", null);
|
||||
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(redisValue);
|
||||
|
||||
service.authorizeDeviceCode("ABCD-1234", "42");
|
||||
|
||||
ArgumentCaptor<DeviceCodeData> captor = ArgumentCaptor.forClass(DeviceCodeData.class);
|
||||
verify(valueOperations).set(eq("device:code:device123"), captor.capture(), eq(15L), eq(TimeUnit.MINUTES));
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED);
|
||||
assertThat(captor.getValue().getUserId()).isEqualTo("42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_accepts_linked_hash_map_from_redis_serializer() {
|
||||
Map<String, Object> redisValue = new HashMap<>();
|
||||
redisValue.put("deviceCode", "device123");
|
||||
redisValue.put("userCode", "ABCD-1234");
|
||||
redisValue.put("status", "AUTHORIZED");
|
||||
redisValue.put("userId", "42");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(redisValue);
|
||||
when(apiTokenService.createToken("42", "device-auth", "[]"))
|
||||
.thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null));
|
||||
|
||||
DeviceTokenResponse response = service.pollToken("device123");
|
||||
|
||||
assertThat(response.error()).isNull();
|
||||
assertThat(response.accessToken()).isEqualTo("sk_device_token");
|
||||
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||
verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_returns_access_token_when_authorized() {
|
||||
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(data);
|
||||
when(apiTokenService.createToken("42", "device-auth", "[]"))
|
||||
.thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null));
|
||||
|
||||
DeviceTokenResponse response = service.pollToken("device123");
|
||||
|
||||
assertThat(response.error()).isNull();
|
||||
assertThat(response.accessToken()).isEqualTo("sk_device_token");
|
||||
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||
verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
|||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.List;
|
||||
|
|
@ -27,6 +29,7 @@ public class PromotionService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final ReviewPermissionChecker permissionChecker;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public PromotionService(PromotionRequestRepository promotionRequestRepository,
|
||||
SkillRepository skillRepository,
|
||||
|
|
@ -34,7 +37,8 @@ public class PromotionService {
|
|||
SkillFileRepository skillFileRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
ReviewPermissionChecker permissionChecker,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
EntityManager entityManager) {
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -42,6 +46,7 @@ public class PromotionService {
|
|||
this.namespaceRepository = namespaceRepository;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -96,6 +101,11 @@ public class PromotionService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request was modified concurrently");
|
||||
}
|
||||
entityManager.detach(request);
|
||||
request.setStatus(ReviewTaskStatus.APPROVED);
|
||||
request.setReviewedBy(reviewerId);
|
||||
request.setReviewComment(comment);
|
||||
request.setReviewedAt(Instant.now());
|
||||
|
||||
Skill sourceSkill = skillRepository.findById(request.getSourceSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId()));
|
||||
|
|
@ -138,9 +148,12 @@ public class PromotionService {
|
|||
.toList();
|
||||
skillFileRepository.saveAll(copiedFiles);
|
||||
|
||||
// Update promotion request with target skill id
|
||||
int targetUpdated = promotionRequestRepository.updateStatusWithVersion(
|
||||
promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, newSkill.getId(), request.getVersion() + 1);
|
||||
if (targetUpdated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request target skill was modified concurrently");
|
||||
}
|
||||
request.setTargetSkillId(newSkill.getId());
|
||||
promotionRequestRepository.save(request);
|
||||
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
newSkill.getId(), newVersion.getId(), reviewerId));
|
||||
|
|
@ -167,7 +180,11 @@ public class PromotionService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request was modified concurrently");
|
||||
}
|
||||
|
||||
return promotionRequestRepository.findById(promotionId).orElse(request);
|
||||
entityManager.detach(request);
|
||||
request.setStatus(ReviewTaskStatus.REJECTED);
|
||||
request.setReviewedBy(reviewerId);
|
||||
request.setReviewComment(comment);
|
||||
request.setReviewedAt(Instant.now());
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,14 @@ public class ReviewPermissionChecker {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Global namespace: only SKILL_ADMIN or SUPER_ADMIN
|
||||
if (namespaceType == NamespaceType.GLOBAL) {
|
||||
return platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Team namespace: namespace ADMIN or OWNER
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -32,19 +33,22 @@ public class ReviewService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final ReviewPermissionChecker permissionChecker;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public ReviewService(ReviewTaskRepository reviewTaskRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
ReviewPermissionChecker permissionChecker,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
EntityManager entityManager) {
|
||||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -91,6 +95,11 @@ public class ReviewService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Review task was modified concurrently");
|
||||
}
|
||||
entityManager.detach(task);
|
||||
task.setStatus(ReviewTaskStatus.APPROVED);
|
||||
task.setReviewedBy(reviewerId);
|
||||
task.setReviewComment(comment);
|
||||
task.setReviewedAt(Instant.now());
|
||||
|
||||
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
|
||||
|
|
@ -106,8 +115,7 @@ public class ReviewService {
|
|||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
skill.getId(), skillVersion.getId(), reviewerId));
|
||||
|
||||
// Reload to return updated state
|
||||
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -134,13 +142,18 @@ public class ReviewService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Review task was modified concurrently");
|
||||
}
|
||||
entityManager.detach(task);
|
||||
task.setStatus(ReviewTaskStatus.REJECTED);
|
||||
task.setReviewedBy(reviewerId);
|
||||
task.setReviewComment(comment);
|
||||
task.setReviewedAt(Instant.now());
|
||||
|
||||
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
|
||||
skillVersion.setStatus(SkillVersionStatus.REJECTED);
|
||||
skillVersionRepository.save(skillVersion);
|
||||
|
||||
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ public class SkillPublishService {
|
|||
version.setTotalSize(totalSize);
|
||||
skillVersionRepository.save(version);
|
||||
|
||||
ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId);
|
||||
reviewTaskRepository.save(reviewTask);
|
||||
|
||||
// 12. Update skill
|
||||
skill.setLatestVersionId(version.getId());
|
||||
skill.setDisplayName(metadata.name());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillRatedEvent;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -11,16 +13,20 @@ import java.util.Optional;
|
|||
@Service
|
||||
public class SkillRatingService {
|
||||
private final SkillRatingRepository ratingRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public SkillRatingService(SkillRatingRepository ratingRepository,
|
||||
SkillRepository skillRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.ratingRepository = ratingRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void rate(Long skillId, String userId, short score) {
|
||||
ensureSkillExists(skillId);
|
||||
if (score < 1 || score > 5) {
|
||||
throw new DomainBadRequestException("error.rating.score.invalid");
|
||||
}
|
||||
|
|
@ -35,7 +41,14 @@ public class SkillRatingService {
|
|||
}
|
||||
|
||||
public Optional<Short> getUserRating(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
return ratingRepository.findBySkillIdAndUserId(skillId, userId)
|
||||
.map(SkillRating::getScore);
|
||||
}
|
||||
|
||||
private void ensureSkillExists(Long skillId) {
|
||||
if (skillRepository.findById(skillId).isEmpty()) {
|
||||
throw new DomainNotFoundException("skill.not_found", skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillStarredEvent;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
|
@ -9,16 +11,20 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
@Service
|
||||
public class SkillStarService {
|
||||
private final SkillStarRepository starRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public SkillStarService(SkillStarRepository starRepository,
|
||||
SkillRepository skillRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.starRepository = starRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void star(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) {
|
||||
return; // idempotent
|
||||
}
|
||||
|
|
@ -28,6 +34,7 @@ public class SkillStarService {
|
|||
|
||||
@Transactional
|
||||
public void unstar(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> {
|
||||
starRepository.delete(star);
|
||||
eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId));
|
||||
|
|
@ -35,6 +42,13 @@ public class SkillStarService {
|
|||
}
|
||||
|
||||
public boolean isStarred(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent();
|
||||
}
|
||||
|
||||
private void ensureSkillExists(Long skillId) {
|
||||
if (skillRepository.findById(skillId).isEmpty()) {
|
||||
throw new DomainNotFoundException("skill.not_found", skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import org.mockito.ArgumentCaptor;
|
|||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import jakarta.persistence.EntityManager;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ class PromotionServiceTest {
|
|||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private ReviewPermissionChecker permissionChecker;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private EntityManager entityManager;
|
||||
|
||||
private PromotionService promotionService;
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ class PromotionServiceTest {
|
|||
void setUp() {
|
||||
promotionService = new PromotionService(
|
||||
promotionRequestRepository, skillRepository, skillVersionRepository,
|
||||
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher);
|
||||
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, entityManager);
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) {
|
||||
|
|
@ -233,6 +235,9 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1))
|
||||
.thenReturn(1);
|
||||
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
|
||||
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
|
||||
|
|
@ -292,8 +297,9 @@ class PromotionServiceTest {
|
|||
assertEquals(NEW_VERSION_ID, event.versionId());
|
||||
assertEquals(REVIEWER_ID, event.publisherId());
|
||||
|
||||
// Verify targetSkillId updated on promotion request
|
||||
verify(promotionRequestRepository).save(pr);
|
||||
verify(entityManager).detach(pr);
|
||||
verify(promotionRequestRepository).updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1);
|
||||
assertEquals(NEW_SKILL_ID, pr.getTargetSkillId());
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +352,9 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
|
||||
when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true);
|
||||
when(promotionRequestRepository.updateStatusWithVersion(any(), any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", NEW_SKILL_ID, pr.getVersion() + 1))
|
||||
.thenReturn(1);
|
||||
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
|
||||
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
|
||||
|
|
@ -382,14 +391,15 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
|
||||
|
||||
PromotionRequest result = promotionService.rejectPromotion(
|
||||
PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN"));
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.REJECTED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
verify(promotionRequestRepository).updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion());
|
||||
verify(entityManager).detach(pr);
|
||||
verify(eventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,11 +66,19 @@ class ReviewPermissionCheckerTest {
|
|||
@Test
|
||||
void skillAdminCannotReviewTeamSkill() {
|
||||
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
|
||||
assertFalse(checker.canReview(task, "user-1",
|
||||
assertTrue(checker.canReview(task, "user-1",
|
||||
NamespaceType.TEAM,
|
||||
Map.of(), Set.of("SKILL_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void superAdminCanReviewTeamSkill() {
|
||||
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
|
||||
assertTrue(checker.canReview(task, "user-1",
|
||||
NamespaceType.TEAM,
|
||||
Map.of(), Set.of("SUPER_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminCannotReviewGlobalSkill() {
|
||||
ReviewTask task = new ReviewTask(1L, 1L, "user-2");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -41,6 +42,7 @@ class ReviewServiceTest {
|
|||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private ReviewPermissionChecker permissionChecker;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private EntityManager entityManager;
|
||||
|
||||
private ReviewService reviewService;
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ class ReviewServiceTest {
|
|||
void setUp() {
|
||||
reviewService = new ReviewService(
|
||||
reviewTaskRepository, skillVersionRepository, skillRepository,
|
||||
namespaceRepository, permissionChecker, eventPublisher);
|
||||
namespaceRepository, permissionChecker, eventPublisher, entityManager);
|
||||
}
|
||||
|
||||
private SkillVersion createDraftSkillVersion() {
|
||||
|
|
@ -164,13 +166,16 @@ class ReviewServiceTest {
|
|||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.approveReview(
|
||||
REVIEW_TASK_ID, REVIEWER_ID, "LGTM",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.APPROVED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
assertEquals("LGTM", result.getReviewComment());
|
||||
assertNotNull(result.getReviewedAt());
|
||||
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
|
||||
assertNotNull(sv.getPublishedAt());
|
||||
assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId());
|
||||
|
|
@ -190,7 +195,6 @@ class ReviewServiceTest {
|
|||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
|
@ -262,13 +266,16 @@ class ReviewServiceTest {
|
|||
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.rejectReview(
|
||||
REVIEW_TASK_ID, REVIEWER_ID, "needs work",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.REJECTED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
assertEquals("needs work", result.getReviewComment());
|
||||
assertNotNull(result.getReviewedAt());
|
||||
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
|
||||
verify(skillVersionRepository).save(sv);
|
||||
verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
|
|
@ -128,6 +129,7 @@ class SkillPublishServiceTest {
|
|||
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
|
||||
verify(skillFileRepository).saveAll(anyList());
|
||||
verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString());
|
||||
verify(reviewTaskRepository).save(any(ReviewTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -168,6 +170,7 @@ class SkillPublishServiceTest {
|
|||
|
||||
assertEquals("smoke-skill-two", result.slug());
|
||||
verify(skillRepository).findByNamespaceIdAndSlug(1L, "smoke-skill-two");
|
||||
verify(reviewTaskRepository).save(any(ReviewTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillRatedEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
|
@ -16,11 +20,17 @@ import static org.mockito.Mockito.*;
|
|||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillRatingServiceTest {
|
||||
@Mock SkillRatingRepository ratingRepository;
|
||||
@Mock SkillRepository skillRepository;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
@InjectMocks SkillRatingService service;
|
||||
|
||||
private Skill skill() {
|
||||
return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rate_creates_new_rating() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty());
|
||||
when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
|
|
@ -32,6 +42,7 @@ class SkillRatingServiceTest {
|
|||
|
||||
@Test
|
||||
void rate_updates_existing_rating() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
SkillRating existing = new SkillRating(1L, "10", (short) 3);
|
||||
when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing));
|
||||
when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
|
@ -45,6 +56,7 @@ class SkillRatingServiceTest {
|
|||
|
||||
@Test
|
||||
void rate_invalid_score_throws() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
assertThatThrownBy(() -> service.rate(1L, "10", (short) 0))
|
||||
.isInstanceOf(DomainBadRequestException.class);
|
||||
assertThatThrownBy(() -> service.rate(1L, "10", (short) 6))
|
||||
|
|
@ -53,8 +65,16 @@ class SkillRatingServiceTest {
|
|||
|
||||
@Test
|
||||
void getUserRating_returns_score() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
SkillRating existing = new SkillRating(1L, "10", (short) 4);
|
||||
when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing));
|
||||
assertThat(service.getUserRating(1L, "10")).hasValue((short) 4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserRating_throws_when_skill_missing() {
|
||||
when(skillRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.getUserRating(99L, "10"))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillStarredEvent;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -16,11 +20,17 @@ import static org.mockito.Mockito.*;
|
|||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillStarServiceTest {
|
||||
@Mock SkillStarRepository starRepository;
|
||||
@Mock SkillRepository skillRepository;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
@InjectMocks SkillStarService service;
|
||||
|
||||
private Skill skill() {
|
||||
return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
void star_skill_creates_record_and_publishes_event() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty());
|
||||
when(starRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
|
|
@ -32,6 +42,7 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void star_skill_already_starred_is_idempotent() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10"))
|
||||
.thenReturn(Optional.of(new SkillStar(1L, "10")));
|
||||
|
||||
|
|
@ -43,6 +54,7 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void unstar_skill_deletes_record_and_publishes_event() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
SkillStar existing = new SkillStar(1L, "10");
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing));
|
||||
|
||||
|
|
@ -54,6 +66,7 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void unstar_skill_not_starred_is_noop() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty());
|
||||
|
||||
service.unstar(1L, "10");
|
||||
|
|
@ -64,8 +77,16 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void isStarred_returns_true_when_exists() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10"))
|
||||
.thenReturn(Optional.of(new SkillStar(1L, "10")));
|
||||
assertThat(service.isStarred(1L, "10")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void star_skill_throws_when_skill_missing() {
|
||||
when(skillRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.star(99L, "10"))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue