fix(review): preserve moderation under concurrent edits

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-01 18:57:13 +08:00
parent 78c8b56190
commit 5e380e50da
23 changed files with 386 additions and 49 deletions

View file

@ -235,11 +235,13 @@
| moderated_by | varchar(128) | 最近一次管理操作人,nullable |
| moderated_at | datetime | 最近一次管理时间,nullable |
| moderation_reason | varchar(500) | 隐藏原因,nullable |
| lock_version | bigint | 乐观锁版本;并发编辑或治理冲突返回 409 |
| created_at | datetime | |
| updated_at | datetime | |
唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改。删除文字评价只清空
`review_text`,保留评分;管理员隐藏评价时也保留评分,避免治理动作改变聚合分数。
`review_text`,保留评分和既有治理状态;管理员隐藏评价时也保留评分,避免作者通过
清空后重新提交绕过治理,或治理动作改变聚合分数。
### user_account

View file

@ -60,6 +60,13 @@ View skill packages with the most stars and highest ratings to discover best pra
3. The rating takes effect immediately and impacts the skill package's average rating
4. You can update your rating at any time
**Writing a Review**:
1. Select "Write a review" on a published skill's detail page
2. Choose 1-5 stars and enter up to 2,000 characters
3. You can edit or clear the text; clearing it keeps the star rating
4. A review hidden by an administrator stays hidden after author edits and becomes public only after an administrator restores it
**Viewing Notifications**:
1. Click the notification icon in the top navigation bar
@ -125,6 +132,24 @@ GET /api/v1/me/stars?page=0&size=20
GET /api/v1/skills/{skillId}/rating
```
**Review APIs**:
```bash
# Public review list
GET /api/v1/skills/{skillId}/reviews?page=0&size=20
# Read, create, or update the current user's review
GET /api/v1/skills/{skillId}/reviews/me
PUT /api/v1/skills/{skillId}/reviews/me
# Clear review text while retaining the star rating
DELETE /api/v1/skills/{skillId}/reviews/me
# SKILL_ADMIN or SUPER_ADMIN moderation
POST /api/v1/admin/skill-reviews/{reviewId}/hide
POST /api/v1/admin/skill-reviews/{reviewId}/restore
```
**Response Example**:
```json
{
@ -137,6 +162,8 @@ GET /api/v1/skills/{skillId}/rating
> **Rating Rules**: Each user can rate each skill package only once. Ratings can be updated but not deleted.
> **Review Rules**: Only published skills can be reviewed, and the public list contains visible reviews only. Refresh and retry after a concurrent-update conflict.
- **Star Count**: A skill package's star count is displayed in search results and on the detail page
- **Average Rating**: A skill package's average rating affects search ranking
- **Notification Settings**: Users can disable certain notification types in their settings

View file

@ -60,6 +60,13 @@ SkillHub 提供了丰富的社交功能,让团队成员可以互动、分享
3. 评分会立即生效,影响技能包的平均评分
4. 可以随时修改评分
**撰写评价**:
1. 在已发布技能的详情页点击「写评价」
2. 选择 1-5 星并填写最多 2000 字的评价
3. 可以修改或清空评价;清空评价不会删除星级评分
4. 被管理员隐藏的评价在重新编辑后仍保持隐藏,只有技能管理员或超级管理员可以恢复公开
**查看通知**:
1. 点击顶部导航栏的通知图标
@ -125,6 +132,24 @@ GET /api/v1/me/stars?page=0&size=20
GET /api/v1/skills/{skillId}/rating
```
**评价接口**:
```bash
# 公开评价列表
GET /api/v1/skills/{skillId}/reviews?page=0&size=20
# 查看、创建或修改自己的评价
GET /api/v1/skills/{skillId}/reviews/me
PUT /api/v1/skills/{skillId}/reviews/me
# 清空评价文字并保留星级评分
DELETE /api/v1/skills/{skillId}/reviews/me
# 技能管理员或超级管理员隐藏、恢复评价
POST /api/v1/admin/skill-reviews/{reviewId}/hide
POST /api/v1/admin/skill-reviews/{reviewId}/restore
```
**响应示例**:
```json
{
@ -137,6 +162,8 @@ GET /api/v1/skills/{skillId}/rating
> **评分规则**:每个用户对每个技能包只能评分一次,可以修改评分但不能删除。
> **评价规则**:只有已发布技能可以评价;公开列表只显示可见评价。并发修改发生冲突时,刷新详情后重试。
- **星标数量**:技能包的星标数会显示在搜索结果和详情页
- **平均评分**:技能包的平均评分会影响搜索排序
- **通知设置**:用户可以在设置中关闭某些类型的通知

View file

@ -1,7 +1,9 @@
package com.iflytek.skillhub.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SkillReviewResponse(
Long id,
String userId,

View file

@ -13,10 +13,12 @@ import jakarta.servlet.http.HttpServletRequest;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.validation.FieldError;
@ -68,6 +70,18 @@ public class GlobalExceptionHandler {
return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request);
}
@ExceptionHandler({
ObjectOptimisticLockingFailureException.class,
DataIntegrityViolationException.class
})
public ResponseEntity<ApiResponse<Void>> handlePersistenceConflict(
RuntimeException ex,
HttpServletRequest request) {
logHandledException(HttpStatus.CONFLICT, "error.request.conflict", request);
return ResponseEntity.status(HttpStatus.CONFLICT).body(
apiResponseFactory.error(409, "error.request.conflict"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
String msg = ex.getBindingResult().getFieldErrors().stream()

View file

@ -39,20 +39,28 @@ public class JpaSkillReviewQueryRepository implements SkillReviewQueryRepository
.toList();
Map<String, UserAccount> authors = userAccountRepository.findByIdIn(authorIds).stream()
.collect(Collectors.toMap(UserAccount::getId, Function.identity()));
return reviews.map(review -> toResponse(review, authors.get(review.getUserId()), viewerId));
return reviews.map(review -> toResponse(
review,
authors.get(review.getUserId()),
viewerId,
includeHidden
));
}
private SkillReviewResponse toResponse(SkillRating review, UserAccount author, String viewerId) {
private SkillReviewResponse toResponse(SkillRating review,
UserAccount author,
String viewerId,
boolean includeModerationDetails) {
return new SkillReviewResponse(
review.getId(),
review.getUserId(),
includeModerationDetails ? review.getUserId() : null,
author != null ? author.getDisplayName() : review.getUserId(),
author != null ? author.getAvatarUrl() : null,
review.getScore(),
review.getReviewText(),
review.getReviewStatus().name(),
review.getUserId().equals(viewerId),
review.getModerationReason(),
includeModerationDetails ? review.getModerationReason() : null,
review.getCreatedAt(),
review.getUpdatedAt()
);

View file

@ -8,6 +8,8 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
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.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillRatingService;
@ -28,6 +30,7 @@ public class SkillReviewAppService {
private static final int MAX_PAGE_SIZE = 100;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final VisibilityChecker visibilityChecker;
private final SkillRatingService ratingService;
private final SkillReviewQueryRepository queryRepository;
@ -35,12 +38,14 @@ public class SkillReviewAppService {
private final RequestIdAccessor requestIdAccessor;
public SkillReviewAppService(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
VisibilityChecker visibilityChecker,
SkillRatingService ratingService,
SkillReviewQueryRepository queryRepository,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.visibilityChecker = visibilityChecker;
this.ratingService = ratingService;
this.queryRepository = queryRepository;
@ -83,7 +88,7 @@ public class SkillReviewAppService {
String reviewText,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles);
requireInteractableSkill(skillId, userId, namespaceRoles, platformRoles);
return toMine(ratingService.upsertReview(skillId, userId, score, reviewText));
}
@ -91,7 +96,7 @@ public class SkillReviewAppService {
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles);
requireInteractableSkill(skillId, userId, namespaceRoles, platformRoles);
return toMine(ratingService.clearReview(skillId, userId));
}
@ -130,6 +135,21 @@ public class SkillReviewAppService {
return skill;
}
private Skill requireInteractableSkill(Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
Skill skill = requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles);
boolean published = skill.getLatestVersionId() != null
&& skillVersionRepository.findById(skill.getLatestVersionId())
.map(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
.orElse(false);
if (!published) {
throw new DomainBadRequestException("error.skillReview.notInteractable");
}
return skill;
}
private SkillReviewMeResponse toMine(SkillRating review) {
return new SkillReviewMeResponse(
true,

View file

@ -4,6 +4,7 @@ ALTER TABLE skill_rating
ADD COLUMN moderated_by VARCHAR(128) REFERENCES user_account(id),
ADD COLUMN moderated_at TIMESTAMPTZ,
ADD COLUMN moderation_reason VARCHAR(500),
ADD COLUMN lock_version BIGINT NOT NULL DEFAULT 0,
ADD CONSTRAINT chk_skill_rating_review_status
CHECK (review_status IN ('VISIBLE', 'HIDDEN'));

View file

@ -192,3 +192,5 @@ error.skillReview.text.required=Review text is required
error.skillReview.text.tooLong=Review text must not exceed {0} characters
error.skillReview.reason.tooLong=Moderation reason must not exceed {0} characters
error.pagination.invalid=Page must be non-negative and size must be between 1 and {0}
error.request.conflict=The data changed while this request was being processed. Refresh and try again.
error.skillReview.notInteractable=Reviews are available only for published skills

View file

@ -182,3 +182,5 @@ error.skillReview.text.required=Текст отзыва обязателен
error.skillReview.text.tooLong=Текст отзыва не должен превышать {0} символов
error.skillReview.reason.tooLong=Причина модерации не должна превышать {0} символов
error.pagination.invalid=Номер страницы не может быть отрицательным, а размер должен быть от 1 до {0}
error.request.conflict=Данные изменились во время обработки запроса. Обновите страницу и повторите попытку.
error.skillReview.notInteractable=Отзывы доступны только для опубликованных навыков

View file

@ -192,3 +192,5 @@ error.skillReview.text.required=评价内容不能为空
error.skillReview.text.tooLong=评价内容不能超过 {0} 个字符
error.skillReview.reason.tooLong=管理原因不能超过 {0} 个字符
error.pagination.invalid=页码不能为负数,每页数量必须在 1 到 {0} 之间
error.request.conflict=数据在请求处理期间已发生变化,请刷新后重试
error.skillReview.notInteractable=仅已发布的技能可以评价

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.storage.StorageAccessException;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
@ -29,11 +30,13 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@ -72,6 +75,8 @@ class GlobalExceptionHandlerTest {
messageSource.addMessage("error.unsupportedMediaType", java.util.Locale.getDefault(), "Unsupported media type");
messageSource.addMessage("error.notAcceptable", java.util.Locale.getDefault(),
"Requested response media type is not acceptable");
messageSource.addMessage("error.request.conflict", java.util.Locale.getDefault(),
"Refresh and try again");
requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
@ -144,6 +149,26 @@ class GlobalExceptionHandlerTest {
.doesNotContain("userId="));
}
@Test
void persistenceConflictsReturn409WithoutLeakingDatabaseDetails() {
attachAppender();
prepareClientErrorRequest("PUT", "/api/v1/skills/10/reviews/me");
for (RuntimeException exception : List.of(
new ObjectOptimisticLockingFailureException(SkillRating.class, 7L),
new DataIntegrityViolationException("duplicate key value violates constraint user-secret"))) {
ResponseEntity<ApiResponse<Void>> response = handler.handlePersistenceConflict(exception, request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(409);
assertThat(response.getBody().msg()).isEqualTo("Refresh and try again");
}
assertThat(loggedMessages()).allSatisfy(message -> assertThat(message)
.doesNotContain("user-secret")
.doesNotContain("duplicate key"));
}
@Test
void handleStorageAccess_shouldLogAuthenticationWithoutStableUserId() {
authenticateRequest();

View file

@ -11,6 +11,9 @@ import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.Skill;
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 com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.social.SkillRating;
@ -32,6 +35,7 @@ import org.springframework.data.domain.Pageable;
class SkillReviewAppServiceTest {
@Mock private SkillRepository skillRepository;
@Mock private SkillVersionRepository skillVersionRepository;
@Mock private SkillRatingService ratingService;
@Mock private SkillReviewQueryRepository queryRepository;
@Mock private AuditLogService auditLogService;
@ -43,6 +47,7 @@ class SkillReviewAppServiceTest {
void setUp() {
service = new SkillReviewAppService(
skillRepository,
skillVersionRepository,
new VisibilityChecker(),
ratingService,
queryRepository,
@ -87,6 +92,22 @@ class SkillReviewAppServiceTest {
verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any());
}
@Test
void unpublishedSkillReviewMutationIsRejectedServerSide() {
Skill skill = publishedSkill(SkillVisibility.PUBLIC);
SkillVersion pending = new SkillVersion(10L, "1.0.0", "owner");
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(100L)).thenReturn(Optional.of(pending));
assertThatThrownBy(() -> service.upsert(
10L, "owner", (short) 5, "not published", Map.of(), Set.of()))
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class)
.hasMessage("error.skillReview.notInteractable");
verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any());
}
@Test
void hideWritesModerationAuditInSameWorkflow() {
SkillRating review = new SkillRating(10L, "author", (short) 4);

View file

@ -14,6 +14,10 @@ public class SkillRating {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
@Column(name = "lock_version", nullable = false)
private Long lockVersion;
@Column(name = "skill_id", nullable = false)
private Long skillId;
@ -69,10 +73,6 @@ public class SkillRating {
public void clearReview() {
this.reviewText = null;
this.reviewStatus = SkillReviewStatus.VISIBLE;
this.moderatedBy = null;
this.moderatedAt = null;
this.moderationReason = null;
this.updatedAt = Instant.now(Clock.systemUTC());
}
@ -143,6 +143,7 @@ public class SkillRating {
// getters
public Long getId() { return id; }
public Long getLockVersion() { return lockVersion; }
public Long getSkillId() { return skillId; }
public String getUserId() { return userId; }
public Short getScore() { return score; }

View file

@ -9,6 +9,7 @@ import org.springframework.data.domain.Pageable;
*/
public interface SkillRatingRepository {
SkillRating save(SkillRating rating);
void flush();
Optional<SkillRating> findById(Long id);
Optional<SkillRating> findBySkillIdAndUserId(Long skillId, String userId);
Page<SkillRating> findVisibleReviewsBySkillId(Long skillId, Pageable pageable);

View file

@ -51,6 +51,7 @@ public class SkillRatingService {
.orElseGet(() -> new SkillRating(skillId, userId, score));
rating.updateReview(score, reviewText);
SkillRating saved = ratingRepository.save(rating);
ratingRepository.flush();
eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score));
return saved;
}
@ -62,21 +63,27 @@ public class SkillRatingService {
.filter(SkillRating::hasReview)
.orElseThrow(() -> new DomainNotFoundException("error.skillReview.notFound"));
rating.clearReview();
return ratingRepository.save(rating);
SkillRating saved = ratingRepository.save(rating);
ratingRepository.flush();
return saved;
}
@Transactional
public SkillRating hideReview(Long reviewId, String moderatorId, String reason) {
SkillRating rating = findReview(reviewId);
rating.hideReview(moderatorId, reason);
return ratingRepository.save(rating);
SkillRating saved = ratingRepository.save(rating);
ratingRepository.flush();
return saved;
}
@Transactional
public SkillRating restoreReview(Long reviewId, String moderatorId) {
SkillRating rating = findReview(reviewId);
rating.restoreReview(moderatorId);
return ratingRepository.save(rating);
SkillRating saved = ratingRepository.save(rating);
ratingRepository.flush();
return saved;
}
public Optional<Short> getUserRating(Long skillId, String userId) {

View file

@ -133,6 +133,26 @@ class SkillRatingServiceTest {
assertThat(result.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE);
}
@Test
void clearAndResubmitReview_preservesHiddenModerationState() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
SkillRating existing = new SkillRating(1L, "user-1", (short) 4);
existing.updateReview((short) 4, "Hidden review");
existing.hideReview("moderator-1", "Policy violation");
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1"))
.thenReturn(Optional.of(existing));
when(ratingRepository.save(existing)).thenReturn(existing);
service.clearReview(1L, "user-1");
SkillRating resubmitted = service.upsertReview(1L, "user-1", (short) 5, "Rewritten review");
assertThat(resubmitted.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN);
assertThat(resubmitted.getModeratedBy()).isEqualTo("moderator-1");
assertThat(resubmitted.getModerationReason()).isEqualTo("Policy violation");
assertThat(resubmitted.getReviewText()).isEqualTo("Rewritten review");
verify(ratingRepository, times(2)).flush();
}
@Test
void moderator_can_hide_and_restore_review() {
SkillRating existing = new SkillRating(1L, "user-1", (short) 4);

View file

@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import en from '@/i18n/locales/en.json'
import ru from '@/i18n/locales/ru.json'
import zh from '@/i18n/locales/zh.json'
import * as componentModule from './skill-reviews'
import * as hookModule from './use-skill-reviews'
describe('skill reviews feature contract', () => {
it('exports the detail-page component and review hooks', () => {
expect(componentModule.SkillReviews).toBeTypeOf('function')
expect(hookModule.useSkillReviews).toBeTypeOf('function')
expect(hookModule.useMySkillReview).toBeTypeOf('function')
expect(hookModule.useUpsertSkillReview).toBeTypeOf('function')
expect(hookModule.useClearSkillReview).toBeTypeOf('function')
expect(hookModule.useModerateSkillReview).toBeTypeOf('function')
})
it('keeps the review interaction copy available in every supported locale', () => {
for (const locale of [en, zh, ru]) {
expect(locale.skillReviews.title).toBeTruthy()
expect(locale.skillReviews.write).toBeTruthy()
expect(locale.skillReviews.empty).toBeTruthy()
expect(locale.skillReviews.hide).toBeTruthy()
expect(locale.skillReviews.restore).toBeTruthy()
}
})
})

View file

@ -0,0 +1,144 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import en from '@/i18n/locales/en.json'
import ru from '@/i18n/locales/ru.json'
import zh from '@/i18n/locales/zh.json'
const mocks = vi.hoisted(() => ({
pages: new Map<number, { items: Array<Record<string, unknown>>; total: number; page: number; size: number }>(),
requestedPages: [] as number[],
saveMutate: vi.fn(),
clearMutate: vi.fn(),
moderateMutate: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
const translations: Record<string, string> = {
'skillReviews.title': 'User reviews',
'skillReviews.count': '{{count}} reviews',
'skillReviews.write': 'Write a review',
'skillReviews.edit': 'Edit my review',
'skillReviews.scoreLabel': 'Your score',
'skillReviews.ratingDisplay': 'Rating: {{score}} out of 5 stars',
'skillReviews.ratingOption': 'Rate {{score}} out of 5 stars',
'skillReviews.reviewTextLabel': 'Review text',
'skillReviews.placeholder': 'Share your experience',
'skillReviews.save': 'Save review',
'skillReviews.cancel': 'Cancel',
'skillReviews.delete': 'Delete review',
'skillReviews.empty': 'No reviews',
'skillReviews.previous': 'Previous',
'skillReviews.next': 'Next',
}
return {
...actual,
useTranslation: () => ({
i18n: { language: 'en' },
t: (key: string, values?: Record<string, unknown>) => Object.entries(values ?? {}).reduce(
(text, [name, value]) => text.replace(`{{${name}}}`, String(value)),
translations[key] ?? key,
),
}),
}
})
vi.mock('@/features/auth/use-auth', () => ({
useAuth: () => ({ isAuthenticated: true, hasRole: () => false }),
}))
vi.mock('@/shared/lib/date-time', () => ({
formatLocalDateTime: (value: string) => value,
}))
vi.mock('@/shared/lib/toast', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('./use-skill-reviews', () => ({
useSkillReviews: (_skillId: number, page: number) => {
mocks.requestedPages.push(page)
return { data: mocks.pages.get(page), isLoading: false, isError: false }
},
useMySkillReview: () => ({
data: {
rated: true,
score: 4,
reviewed: true,
reviewId: 7,
reviewText: 'Useful review',
status: 'VISIBLE',
updatedAt: '2026-09-01T00:00:00Z',
},
}),
useUpsertSkillReview: () => ({ mutate: mocks.saveMutate, isPending: false }),
useClearSkillReview: () => ({ mutate: mocks.clearMutate, isPending: false }),
useModerateSkillReview: () => ({ mutate: mocks.moderateMutate, isPending: false }),
}))
import { SkillReviews } from './skill-reviews'
describe('skill reviews', () => {
beforeEach(() => {
mocks.pages.clear()
mocks.pages.set(0, { items: [], total: 0, page: 0, size: 20 })
mocks.requestedPages.length = 0
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('exposes an accessible rating group and labelled review editor', () => {
render(<SkillReviews skillId={10} canInteract onRequireLogin={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit my review' }))
const rating = screen.getByRole('radiogroup', { name: 'Your score' })
expect(rating).toBeTruthy()
expect(screen.getByRole('radio', { name: 'Rate 4 out of 5 stars' }).getAttribute('aria-checked'))
.toBe('true')
expect(screen.getByRole('textbox', { name: 'Review text' })).toHaveProperty('value', 'Useful review')
})
it('keeps a way back when a refetch empties the last page', () => {
mocks.pages.set(0, { items: [], total: 21, page: 0, size: 20 })
mocks.pages.set(1, {
items: [{
id: 8,
displayName: 'Alice',
score: 5,
reviewText: 'Great',
status: 'VISIBLE',
authoredByViewer: false,
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
}],
total: 21,
page: 1,
size: 20,
})
const view = render(<SkillReviews skillId={10} canInteract onRequireLogin={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
expect(mocks.requestedPages).toContain(1)
mocks.pages.set(1, { items: [], total: 20, page: 1, size: 20 })
view.rerender(<SkillReviews skillId={10} canInteract onRequireLogin={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Previous' }))
expect(mocks.requestedPages.at(-1)).toBe(0)
})
it('keeps review interaction copy in every supported locale', () => {
for (const locale of [en, zh, ru]) {
expect(locale.skillReviews.ratingDisplay).toBeTruthy()
expect(locale.skillReviews.ratingOption).toBeTruthy()
expect(locale.skillReviews.reviewTextLabel).toBeTruthy()
expect(locale.skillReviews.hide).toBeTruthy()
expect(locale.skillReviews.restore).toBeTruthy()
}
})
})

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2, MessageSquare, ShieldAlert, Star } from 'lucide-react'
import { useAuth } from '@/features/auth/use-auth'
@ -29,16 +29,37 @@ function ReviewStars({ value, onChange, disabled = false }: {
onChange?: (value: number) => void
disabled?: boolean
}) {
const { t } = useTranslation()
if (!onChange) {
return (
<div className="flex items-center gap-1" aria-label={t('skillReviews.ratingDisplay', { score: value })}>
{[1, 2, 3, 4, 5].map((score) => (
<Star
key={score}
aria-hidden="true"
className={cn(
'h-4 w-4',
score <= value ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground/40',
)}
/>
))}
</div>
)
}
return (
<div className="flex items-center gap-1" aria-label={`${value}/5`}>
<div className="flex items-center gap-1" role="radiogroup" aria-label={t('skillReviews.scoreLabel')}>
{[1, 2, 3, 4, 5].map((score) => (
<button
key={score}
type="button"
className={cn('rounded p-0.5', onChange && 'hover:scale-110 transition-transform')}
onClick={() => onChange?.(score)}
disabled={disabled || !onChange}
aria-label={`${score}`}
role="radio"
aria-checked={score === value}
onClick={() => onChange(score)}
disabled={disabled}
aria-label={t('skillReviews.ratingOption', { score })}
>
<Star className={cn(
'h-4 w-4',
@ -56,6 +77,7 @@ function ReviewEditor({ skillId, review, onDone }: {
onDone: () => void
}) {
const { t } = useTranslation()
const reviewTextId = useId()
const [score, setScore] = useState(review?.rated ? review.score : 5)
const [reviewText, setReviewText] = useState(review?.reviewText ?? '')
const save = useUpsertSkillReview(skillId)
@ -93,7 +115,9 @@ function ReviewEditor({ skillId, review, onDone }: {
<span className="text-sm font-medium">{t('skillReviews.scoreLabel')}</span>
<ReviewStars value={score} onChange={setScore} disabled={pending} />
</div>
<label htmlFor={reviewTextId} className="sr-only">{t('skillReviews.reviewTextLabel')}</label>
<Textarea
id={reviewTextId}
value={reviewText}
onChange={(event) => setReviewText(event.target.value)}
maxLength={2000}
@ -193,6 +217,11 @@ export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillRevi
const canModerate = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const totalPages = reviews.data ? Math.ceil(reviews.data.total / reviews.data.size) : 0
const finishEditing = () => {
setEditing(false)
setPage(0)
}
const startEditing = () => {
if (!isAuthenticated) {
onRequireLogin()
@ -232,7 +261,7 @@ export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillRevi
key={`${mine.data?.reviewId ?? 'new'}-${mine.data?.updatedAt ?? ''}`}
skillId={skillId}
review={mine.data}
onDone={() => setEditing(false)}
onDone={finishEditing}
/>
) : null}
@ -257,12 +286,12 @@ export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillRevi
</div>
)}
{totalPages > 1 ? (
{page > 0 || totalPages > 1 ? (
<div className="flex items-center justify-end gap-2 border-t border-border/50 pt-4">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((value) => value - 1)}>
{t('skillReviews.previous')}
</Button>
<span className="text-xs text-muted-foreground">{page + 1}/{totalPages}</span>
<span className="text-xs text-muted-foreground">{page + 1}/{Math.max(totalPages, 1)}</span>
<Button variant="outline" size="sm" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}>
{t('skillReviews.next')}
</Button>

View file

@ -1305,6 +1305,9 @@
"write": "Write a review",
"edit": "Edit my review",
"scoreLabel": "Your score",
"ratingDisplay": "Rating: {{score}} out of 5 stars",
"ratingOption": "Rate {{score}} out of 5 stars",
"reviewTextLabel": "Review text",
"placeholder": "Share what worked well and what others should know.",
"save": "Save review",
"cancel": "Cancel",

View file

@ -1517,6 +1517,9 @@
"write": "Написать отзыв",
"edit": "Изменить мой отзыв",
"scoreLabel": "Ваша оценка",
"ratingDisplay": "Оценка: {{score}} из 5 звёзд",
"ratingOption": "Поставить {{score}} из 5 звёзд",
"reviewTextLabel": "Текст отзыва",
"placeholder": "Расскажите об опыте использования и важных деталях.",
"save": "Сохранить отзыв",
"cancel": "Отмена",

View file

@ -1305,6 +1305,9 @@
"write": "写评价",
"edit": "编辑我的评价",
"scoreLabel": "你的评分",
"ratingDisplay": "评分:5 星中 {{score}} 星",
"ratingOption": "评为 5 星中 {{score}} 星",
"reviewTextLabel": "评价内容",
"placeholder": "说说使用体验,以及其他用户需要了解的信息。",
"save": "保存评价",
"cancel": "取消",