feat(dashboard): add kinds CSV query param to /api/dashboard/activity
#302
@@ -1,5 +1,7 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public enum AuditKind {
|
||||
|
||||
/** Payload: none */
|
||||
@@ -24,5 +26,10 @@ public enum AuditKind {
|
||||
COMMENT_ADDED,
|
||||
|
||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||
MENTION_CREATED,
|
||||
MENTION_CREATED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -35,8 +36,7 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
ORDER BY a.happened_at
|
||||
) AS prev_happened_at
|
||||
FROM audit_log a
|
||||
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED',
|
||||
'BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED')
|
||||
WHERE a.kind IN (:kinds)
|
||||
AND a.document_id IS NOT NULL
|
||||
),
|
||||
sessions_marked AS (
|
||||
@@ -108,7 +108,8 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
""", nativeQuery = true)
|
||||
List<ActivityFeedRow> findRolledUpActivityFeed(
|
||||
@Param("currentUserId") String currentUserId,
|
||||
@Param("limit") int limit);
|
||||
@Param("limit") int limit,
|
||||
@Param("kinds") Collection<String> kinds);
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
|
||||
@@ -17,7 +17,12 @@ public class AuditLogQueryService {
|
||||
}
|
||||
|
||||
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
|
||||
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit);
|
||||
return findActivityFeed(currentUserId, limit, AuditKind.ROLLUP_ELIGIBLE);
|
||||
}
|
||||
|
||||
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit, Set<AuditKind> kinds) {
|
||||
List<String> kindNames = kinds.stream().map(Enum::name).toList();
|
||||
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, kindNames);
|
||||
}
|
||||
|
||||
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
||||
@@ -9,6 +13,7 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -35,8 +40,12 @@ public class DashboardController {
|
||||
@GetMapping("/activity")
|
||||
public List<ActivityFeedItemDTO> getActivity(
|
||||
Authentication authentication,
|
||||
@RequestParam(defaultValue = "7") int limit) {
|
||||
@RequestParam(defaultValue = "7") int limit,
|
||||
@Parameter(description = "Filter by audit kinds; omit for all rollup-eligible kinds",
|
||||
array = @ArraySchema(schema = @Schema(implementation = AuditKind.class)))
|
||||
@RequestParam(required = false) Set<AuditKind> kinds) {
|
||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
||||
return dashboardService.getActivity(userId, Math.min(limit, 40));
|
||||
Set<AuditKind> effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds;
|
||||
return dashboardService.getActivity(userId, Math.min(limit, 40), effectiveKinds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
@@ -110,8 +111,8 @@ public class DashboardService {
|
||||
);
|
||||
}
|
||||
|
||||
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit) {
|
||||
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit);
|
||||
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit, Set<AuditKind> kinds) {
|
||||
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds);
|
||||
|
||||
List<UUID> docIds = rows.stream()
|
||||
.map(ActivityFeedRow::getDocumentId)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuditLogQueryServiceTest {
|
||||
|
||||
@Mock AuditLogQueryRepository queryRepository;
|
||||
@InjectMocks AuditLogQueryService auditLogQueryService;
|
||||
|
||||
@Test
|
||||
void findActivityFeed_withKinds_passesKindNamesToRepository() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
Set<AuditKind> kinds = Set.of(AuditKind.FILE_UPLOADED);
|
||||
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<ActivityFeedRow> result = auditLogQueryService.findActivityFeed(userId, 10, kinds);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||
eq(List.of("FILE_UPLOADED")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findActivityFeed_twoArg_defaultsToAllRollupEligibleKinds() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
auditLogQueryService.findActivityFeed(userId, 10);
|
||||
|
||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,8 @@ class AuditLogQueryRepositoryIntegrationTest {
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')"
|
||||
})
|
||||
void findRolledUpActivityFeed_returnsAnnotationEntry() {
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10,
|
||||
List.of("TEXT_SAVED","FILE_UPLOADED","ANNOTATION_CREATED","BLOCK_REVIEWED","COMMENT_ADDED","MENTION_CREATED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.dashboard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -36,6 +37,9 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
static final UUID DOC_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||
static final UUID OTHER_DOC_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
|
||||
static final List<String> ALL_ELIGIBLE_KINDS =
|
||||
AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList();
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired AuditLogQueryRepository auditLogQueryRepository;
|
||||
@@ -97,7 +101,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
|
||||
}
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
ActivityFeedRow row = rows.get(0);
|
||||
@@ -119,7 +123,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart.plusSeconds(300));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(2);
|
||||
@@ -136,7 +140,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(i * 60L * 30L));
|
||||
}
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(30);
|
||||
@@ -152,7 +156,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(120));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(3);
|
||||
assertThat(rows).allSatisfy(r -> {
|
||||
@@ -170,7 +174,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "METADATA_UPDATED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(120));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
|
||||
@@ -184,7 +188,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(300));
|
||||
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(900));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows).anySatisfy(r -> {
|
||||
@@ -209,7 +213,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
insertReplyNotification(USER_ID, DOC_ID, commentId);
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).anySatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isTrue()
|
||||
@@ -223,7 +227,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isFalse()
|
||||
@@ -236,7 +240,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of());
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isFalse()
|
||||
@@ -251,7 +255,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
insertReplyNotification(OTHER_USER_ID, DOC_ID, commentId);
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isFalse()
|
||||
@@ -264,7 +268,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", USER_ID.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).anySatisfy(r ->
|
||||
assertThat(r.isYouMentioned()).isTrue()
|
||||
@@ -278,7 +282,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
|
||||
@@ -292,7 +296,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
Instant.parse("2026-04-20T10:00:00Z"),
|
||||
Map.of("commentId", commentId.toString(), "mentionedUserId", USER_ID.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
|
||||
@@ -304,7 +308,7 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("blockId", "ccc", "pageNumber", "1"));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isNull();
|
||||
@@ -316,10 +320,92 @@ class AuditLogQueryRepositoryRolledUpTest {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", OTHER_USER_ID.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouMentioned()).isFalse()
|
||||
);
|
||||
}
|
||||
|
||||
// ─── kinds filter ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_with_single_kind_returns_only_that_kind() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("FILE_UPLOADED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("FILE_UPLOADED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_with_multiple_kinds_returns_union() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("TEXT_SAVED", "FILE_UPLOADED"));
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows).extracting(ActivityFeedRow::getKind)
|
||||
.containsExactlyInAnyOrder("TEXT_SAVED", "FILE_UPLOADED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_with_default_returns_all_six_eligible_kinds() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "BLOCK_REVIEWED", base.plusSeconds(7300));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(7360));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED", base.plusSeconds(7420));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40,
|
||||
List.of("TEXT_SAVED", "FILE_UPLOADED", "ANNOTATION_CREATED",
|
||||
"BLOCK_REVIEWED", "COMMENT_ADDED", "MENTION_CREATED"));
|
||||
|
||||
assertThat(rows).hasSize(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_excludes_rows_not_in_filter_set() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_rollup_still_works_when_kind_set_is_filtered_to_single_rollable_kind() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T09:00:00Z");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
|
||||
}
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(20));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
@@ -15,10 +16,12 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
@@ -134,7 +137,7 @@ class DashboardControllerTest {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -147,11 +150,66 @@ class DashboardControllerTest {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity").param("limit", "9999"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.mockito.Mockito.verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40));
|
||||
verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40), any());
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/activity — kinds param ───────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_parsesKinds_fromCsvQueryParam() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity")
|
||||
.param("kinds", "FILE_UPLOADED", "TEXT_SAVED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.FILE_UPLOADED, AuditKind.TEXT_SAVED)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_returns400_forUnknownKindValue() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "INVALID_KIND"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_defaults_to_rollupEligible_whenKindsAbsent() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.ROLLUP_ELIGIBLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_treats_single_valid_kind_as_filter() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "COMMENT_ADDED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.COMMENT_ADDED)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
@@ -16,13 +18,17 @@ import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -82,7 +88,7 @@ class DashboardServiceTest {
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED");
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row, row));
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row, row));
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(docId).title("Familienbrief").originalFilename("f.pdf")
|
||||
@@ -90,7 +96,7 @@ class DashboardServiceTest {
|
||||
.build();
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(2);
|
||||
assertThat(items.get(0).documentTitle()).isEqualTo("Familienbrief");
|
||||
@@ -106,13 +112,13 @@ class DashboardServiceTest {
|
||||
UUID commentId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row));
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isEqualTo(commentId);
|
||||
@@ -126,14 +132,14 @@ class DashboardServiceTest {
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row));
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, annotationId));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).annotationId()).isEqualTo(annotationId);
|
||||
@@ -145,12 +151,12 @@ class DashboardServiceTest {
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row));
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isNull();
|
||||
@@ -158,6 +164,28 @@ class DashboardServiceTest {
|
||||
verify(commentService, never()).findAnnotationIdsByIds(anyList());
|
||||
}
|
||||
|
||||
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
|
||||
|
||||
@Test
|
||||
void pulse_uses_all_rollup_eligible_kinds_never_calls_kinds_filtered_overload() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
PulseStatsRow stats = new PulseStatsRow() {
|
||||
public long getPages() { return 0; }
|
||||
public long getAnnotated() { return 0; }
|
||||
public long getTranscribed() { return 0; }
|
||||
public long getUploaded() { return 0; }
|
||||
public long getYourPages() { return 0; }
|
||||
};
|
||||
when(auditLogQueryService.getPulseStats(any(OffsetDateTime.class), any(UUID.class)))
|
||||
.thenReturn(stats);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 50)).thenReturn(List.of());
|
||||
|
||||
dashboardService.getPulse(userId);
|
||||
|
||||
verify(auditLogQueryService).findActivityFeed(userId, 50);
|
||||
verify(auditLogQueryService, never()).findActivityFeed(any(UUID.class), anyInt(), any(Set.class));
|
||||
}
|
||||
|
||||
private ActivityFeedRow mockFeedRow(UUID docId, String kind) {
|
||||
return mockFeedRow(docId, kind, null);
|
||||
}
|
||||
|
||||
@@ -452,38 +452,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/comments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentComments"];
|
||||
put?: never;
|
||||
post: operations["postDocumentComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/comments/{commentId}/replies": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["replyToDocumentComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -500,38 +468,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getAnnotationComments"];
|
||||
put?: never;
|
||||
post: operations["postAnnotationComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["replyToAnnotationComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/quick-upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1815,17 +1751,17 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
personType?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
};
|
||||
SenderModel: {
|
||||
/** Format: uuid */
|
||||
@@ -2043,7 +1979,6 @@ export interface components {
|
||||
count: number;
|
||||
/** Format: date-time */
|
||||
happenedAtUntil?: string;
|
||||
// MANUALLY ADDED — re-run `npm run generate:api` after the next backend deployment and re-add these fields if they are dropped
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds.
|
||||
@@ -3144,81 +3079,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentComments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postDocumentComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
replyToDocumentComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listAnnotations: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3267,82 +3127,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getAnnotationComments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postAnnotationComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
replyToAnnotationComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
quickUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4395,6 +4179,8 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED")[];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { components, operations } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
type NotificationDTO = components['schemas']['NotificationDTO'];
|
||||
type AuditKind = NonNullable<operations['getActivity']['parameters']['query']>['kinds'] extends
|
||||
| (infer K)[]
|
||||
| undefined
|
||||
? K
|
||||
: never;
|
||||
|
||||
export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare';
|
||||
|
||||
@@ -14,6 +19,13 @@ const VALID_FILTERS: FilterValue[] = [
|
||||
'kommentare'
|
||||
];
|
||||
|
||||
// fuer-dich stays client-side: youMentioned || youParticipated cannot be expressed as a kinds filter
|
||||
const KINDS_FOR_FILTER: Partial<Record<FilterValue, AuditKind[]>> = {
|
||||
hochgeladen: ['FILE_UPLOADED'],
|
||||
transkription: ['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED'],
|
||||
kommentare: ['COMMENT_ADDED', 'MENTION_CREATED']
|
||||
};
|
||||
|
||||
function parseFilter(raw: string | null): FilterValue {
|
||||
if (raw && (VALID_FILTERS as string[]).includes(raw)) return raw as FilterValue;
|
||||
return 'alle';
|
||||
@@ -23,9 +35,10 @@ export async function load({ fetch, url }) {
|
||||
const api = createApiClient(fetch);
|
||||
const filter = parseFilter(url.searchParams.get('filter'));
|
||||
const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 40);
|
||||
const kinds = KINDS_FOR_FILTER[filter];
|
||||
|
||||
const [activityResult, unreadResult] = await Promise.allSettled([
|
||||
api.GET('/api/dashboard/activity', { params: { query: { limit } } }),
|
||||
api.GET('/api/dashboard/activity', { params: { query: { limit, ...(kinds && { kinds }) } } }),
|
||||
api.GET('/api/notifications', {
|
||||
params: { query: { read: false, page: 0, size: 20 } }
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { page, navigating } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { notificationStore, type NotificationItem } from '$lib/stores/notifications.svelte';
|
||||
import ChronikFuerDichBox from '$lib/components/chronik/ChronikFuerDichBox.svelte';
|
||||
@@ -11,7 +11,7 @@ import ChronikEmptyState from '$lib/components/chronik/ChronikEmptyState.svelte'
|
||||
import ChronikErrorCard from '$lib/components/chronik/ChronikErrorCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { FilterValue } from './+page.server';
|
||||
import { filterFeed } from './feedFilters';
|
||||
import { applyClientFilter } from './clientFilter';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
@@ -26,14 +26,6 @@ interface Props {
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
// Mirror the current filter into a local state we can update on pill change.
|
||||
// The effect syncs whenever the server-loaded filter changes (e.g. after goto).
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived -- we need this mutable for onFilterChange optimism before goto() resolves
|
||||
let activeFilter = $state<FilterValue>('alle');
|
||||
$effect(() => {
|
||||
activeFilter = data.filter;
|
||||
});
|
||||
|
||||
// Prefer the live SSE singleton for unread items so newly arriving mentions
|
||||
// prepend without a reload. On first mount, seed from the server-loaded unread
|
||||
// set if the singleton hasn't populated yet.
|
||||
@@ -74,7 +66,6 @@ const unread = $derived<NotificationItem[]>(
|
||||
);
|
||||
|
||||
async function onFilterChange(v: FilterValue) {
|
||||
activeFilter = v;
|
||||
const url = new URL(page.url);
|
||||
if (v === 'alle') url.searchParams.delete('filter');
|
||||
else url.searchParams.set('filter', v);
|
||||
@@ -93,7 +84,7 @@ async function onMarkAllRead() {
|
||||
await notificationStore.markAllRead();
|
||||
}
|
||||
|
||||
const displayFeed = $derived(filterFeed(data.activityFeed, activeFilter));
|
||||
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
||||
|
||||
const isEmpty = $derived(displayFeed.length === 0);
|
||||
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
|
||||
@@ -120,15 +111,17 @@ function retry() {
|
||||
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
||||
|
||||
<div class="mt-6">
|
||||
<ChronikFilterPills value={activeFilter} onChange={onFilterChange} />
|
||||
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
||||
</div>
|
||||
|
||||
{#if isEmpty}
|
||||
<div class="mt-8">
|
||||
<ChronikEmptyState variant={emptyVariant} />
|
||||
</div>
|
||||
{:else}
|
||||
<ChronikTimeline items={displayFeed} />
|
||||
{/if}
|
||||
<div aria-live="polite" aria-atomic="false" aria-busy={!!navigating.type}>
|
||||
{#if isEmpty}
|
||||
<div class="mt-8">
|
||||
<ChronikEmptyState variant={emptyVariant} />
|
||||
</div>
|
||||
{:else}
|
||||
<ChronikTimeline items={displayFeed} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
94
frontend/src/routes/chronik/clientFilter.test.ts
Normal file
94
frontend/src/routes/chronik/clientFilter.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyClientFilter } from './clientFilter';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Item = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
function makeItem(overrides: Partial<Item> = {}): Item {
|
||||
return {
|
||||
kind: 'FILE_UPLOADED',
|
||||
documentId: 'd1',
|
||||
documentTitle: 'Brief A',
|
||||
happenedAt: '2026-04-20T10:00:00Z',
|
||||
youMentioned: false,
|
||||
youParticipated: false,
|
||||
count: 1,
|
||||
actor: null,
|
||||
happenedAtUntil: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyClientFilter', () => {
|
||||
describe('non-fuer-dich filters pass through unchanged', () => {
|
||||
it('alle returns all items', () => {
|
||||
const items = [makeItem(), makeItem({ kind: 'COMMENT_ADDED' })];
|
||||
expect(applyClientFilter(items, 'alle')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hochgeladen passes through (server already filtered by kinds)', () => {
|
||||
const items = [makeItem({ kind: 'FILE_UPLOADED' }), makeItem({ kind: 'COMMENT_ADDED' })];
|
||||
expect(applyClientFilter(items, 'hochgeladen')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('transkription passes through (server already filtered by kinds)', () => {
|
||||
const items = [makeItem({ kind: 'TEXT_SAVED' })];
|
||||
expect(applyClientFilter(items, 'transkription')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('kommentare passes through (server already filtered by kinds)', () => {
|
||||
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
|
||||
expect(applyClientFilter(items, 'kommentare')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fuer-dich applies youMentioned || youParticipated predicate', () => {
|
||||
it('includes items where youMentioned is true', () => {
|
||||
const items = [makeItem({ youMentioned: true })];
|
||||
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes items where youParticipated is true', () => {
|
||||
const items = [makeItem({ youParticipated: true })];
|
||||
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes items where both flags are true', () => {
|
||||
const items = [makeItem({ youMentioned: true, youParticipated: true })];
|
||||
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes items where neither flag is set', () => {
|
||||
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
|
||||
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('MENTION_CREATED without youMentioned flag is excluded', () => {
|
||||
// youMentioned is set by the backend only when this event is directed at the current user.
|
||||
// Unlike the old feedFilters.ts, we no longer include MENTION_CREATED unconditionally —
|
||||
// that incorrectly showed mentions directed at OTHER users.
|
||||
const items = [
|
||||
makeItem({ kind: 'MENTION_CREATED', youMentioned: false, youParticipated: false })
|
||||
];
|
||||
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('MENTION_CREATED with youMentioned true is included', () => {
|
||||
const items = [makeItem({ kind: 'MENTION_CREATED', youMentioned: true })];
|
||||
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters mixed items to only personally relevant ones', () => {
|
||||
const items = [
|
||||
makeItem({ kind: 'FILE_UPLOADED' }),
|
||||
makeItem({ kind: 'COMMENT_ADDED', youParticipated: true }),
|
||||
makeItem({ kind: 'MENTION_CREATED', youMentioned: true }),
|
||||
makeItem({ kind: 'TEXT_SAVED' })
|
||||
];
|
||||
const result = applyClientFilter(items, 'fuer-dich');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].kind).toBe('COMMENT_ADDED');
|
||||
expect(result[1].kind).toBe('MENTION_CREATED');
|
||||
});
|
||||
});
|
||||
});
|
||||
18
frontend/src/routes/chronik/clientFilter.ts
Normal file
18
frontend/src/routes/chronik/clientFilter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { FilterValue } from './+page.server';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
// All server-side filters (hochgeladen, transkription, kommentare) are already applied via
|
||||
// kinds param — the server returns only matching items, so client-side is a no-op.
|
||||
// fuer-dich is the only filter that requires client-side narrowing: youMentioned and
|
||||
// youParticipated are user-scoped flags that cannot be expressed as a kinds filter.
|
||||
export function applyClientFilter(
|
||||
items: ActivityFeedItemDTO[],
|
||||
filter: FilterValue
|
||||
): ActivityFeedItemDTO[] {
|
||||
if (filter === 'fuer-dich') {
|
||||
return items.filter((item) => item.youMentioned || item.youParticipated);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterFeed } from './feedFilters';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Item = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
function makeItem(overrides: Partial<Item> = {}): Item {
|
||||
return {
|
||||
kind: 'FILE_UPLOADED',
|
||||
documentId: 'd1',
|
||||
documentTitle: 'Brief A',
|
||||
happenedAt: '2026-04-20T10:00:00Z',
|
||||
youMentioned: false,
|
||||
youParticipated: false,
|
||||
count: 1,
|
||||
actor: null,
|
||||
happenedAtUntil: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('filterFeed', () => {
|
||||
describe('alle', () => {
|
||||
it('returns all items regardless of kind', () => {
|
||||
const items = [
|
||||
makeItem({ kind: 'FILE_UPLOADED' }),
|
||||
makeItem({ kind: 'COMMENT_ADDED' }),
|
||||
makeItem({ kind: 'MENTION_CREATED' })
|
||||
];
|
||||
expect(filterFeed(items, 'alle')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fuer-dich', () => {
|
||||
it('includes MENTION_CREATED items', () => {
|
||||
const items = [makeItem({ kind: 'MENTION_CREATED' })];
|
||||
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes items where youMentioned is true', () => {
|
||||
const items = [makeItem({ kind: 'COMMENT_ADDED', youMentioned: true })];
|
||||
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes items where youParticipated is true', () => {
|
||||
const items = [makeItem({ kind: 'COMMENT_ADDED', youParticipated: true })];
|
||||
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes FILE_UPLOADED with no participation', () => {
|
||||
const items = [makeItem({ kind: 'FILE_UPLOADED' })];
|
||||
expect(filterFeed(items, 'fuer-dich')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes COMMENT_ADDED with no mention and no participation', () => {
|
||||
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
|
||||
expect(filterFeed(items, 'fuer-dich')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hochgeladen', () => {
|
||||
it('includes only FILE_UPLOADED items', () => {
|
||||
const items = [
|
||||
makeItem({ kind: 'FILE_UPLOADED' }),
|
||||
makeItem({ kind: 'COMMENT_ADDED', youParticipated: true })
|
||||
];
|
||||
expect(filterFeed(items, 'hochgeladen')).toHaveLength(1);
|
||||
expect(filterFeed(items, 'hochgeladen')[0].kind).toBe('FILE_UPLOADED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transkription', () => {
|
||||
it('includes TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED', () => {
|
||||
const items = [
|
||||
makeItem({ kind: 'TEXT_SAVED' }),
|
||||
makeItem({ kind: 'BLOCK_REVIEWED' }),
|
||||
makeItem({ kind: 'ANNOTATION_CREATED' }),
|
||||
makeItem({ kind: 'FILE_UPLOADED' })
|
||||
];
|
||||
expect(filterFeed(items, 'transkription')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kommentare', () => {
|
||||
it('includes COMMENT_ADDED and MENTION_CREATED', () => {
|
||||
const items = [
|
||||
makeItem({ kind: 'COMMENT_ADDED' }),
|
||||
makeItem({ kind: 'MENTION_CREATED' }),
|
||||
makeItem({ kind: 'FILE_UPLOADED' })
|
||||
];
|
||||
expect(filterFeed(items, 'kommentare')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { FilterValue } from './+page.server';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
export function filterFeed(
|
||||
items: ActivityFeedItemDTO[],
|
||||
filter: FilterValue
|
||||
): ActivityFeedItemDTO[] {
|
||||
switch (filter) {
|
||||
case 'alle':
|
||||
return items;
|
||||
case 'fuer-dich':
|
||||
return items.filter(
|
||||
(i) => i.kind === 'MENTION_CREATED' || i.youMentioned || i.youParticipated
|
||||
);
|
||||
case 'hochgeladen':
|
||||
return items.filter((i) => i.kind === 'FILE_UPLOADED');
|
||||
case 'transkription':
|
||||
return items.filter(
|
||||
(i) =>
|
||||
i.kind === 'TEXT_SAVED' || i.kind === 'BLOCK_REVIEWED' || i.kind === 'ANNOTATION_CREATED'
|
||||
);
|
||||
case 'kommentare':
|
||||
return items.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED');
|
||||
}
|
||||
}
|
||||
@@ -13,36 +13,23 @@ function buildUrl(search = ''): URL {
|
||||
return new URL(`http://localhost/chronik${search}`);
|
||||
}
|
||||
|
||||
function mockSuccess() {
|
||||
mockApi.GET.mockImplementation((path: string) => {
|
||||
if (path === '/api/dashboard/activity') {
|
||||
return Promise.resolve({ response: { ok: true }, data: [] });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('chronik/load', () => {
|
||||
it('requests the activity feed with a 40-item limit', async () => {
|
||||
mockApi.GET.mockImplementation((path: string) => {
|
||||
if (path === '/api/dashboard/activity') {
|
||||
return Promise.resolve({ response: { ok: true }, data: [] });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||
});
|
||||
|
||||
await load({ fetch, url: buildUrl() } as never);
|
||||
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40 } }
|
||||
});
|
||||
});
|
||||
|
||||
describe('chronik/load — core', () => {
|
||||
it('requests only unread notifications for Für-dich', async () => {
|
||||
mockApi.GET.mockImplementation((path: string) => {
|
||||
if (path === '/api/dashboard/activity') {
|
||||
return Promise.resolve({ response: { ok: true }, data: [] });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||
});
|
||||
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl() } as never);
|
||||
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
|
||||
params: { query: { read: false, page: 0, size: 20 } }
|
||||
});
|
||||
@@ -82,7 +69,6 @@ describe('chronik/load', () => {
|
||||
|
||||
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
|
||||
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||
|
||||
const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||
expect(validResult.filter).toBe('fuer-dich');
|
||||
|
||||
@@ -91,3 +77,59 @@ describe('chronik/load', () => {
|
||||
expect(invalidResult.filter).toBe('alle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chronik/load — kinds param per filter', () => {
|
||||
it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl() } as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40 } }
|
||||
});
|
||||
});
|
||||
|
||||
it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40 } }
|
||||
});
|
||||
});
|
||||
|
||||
it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=hochgeladen') } as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } }
|
||||
});
|
||||
});
|
||||
|
||||
it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=transkription') } as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: {
|
||||
query: {
|
||||
limit: 40,
|
||||
kinds: expect.arrayContaining(['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED'])
|
||||
}
|
||||
}
|
||||
});
|
||||
const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity');
|
||||
expect(call[1].params.query.kinds).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=kommentare') } as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: {
|
||||
query: {
|
||||
limit: 40,
|
||||
kinds: expect.arrayContaining(['COMMENT_ADDED', 'MENTION_CREATED'])
|
||||
}
|
||||
}
|
||||
});
|
||||
const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity');
|
||||
expect(call[1].params.query.kinds).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user