feat(audit): replace hour-trunc dedupe with LAG() session rollup (120-min gap)
Rewrites the activity feed query to group consecutive events on the same (actor, document, kind) into sessions separated by >120 min gaps. A session becomes one row with count = events-in-session and happenedAtUntil = last event timestamp. Singletons keep count=1 / happenedAtUntil=null. Algorithm: LAG() to get the previous event's timestamp in the same partition, mark a new session when gap > 7200s, then SUM() over an unbounded preceding window yields a running session_id. Aggregation groups by session_id. COMMENT_ADDED and MENTION_CREATED always start a new session — these kinds never roll up so each event stays its own row. Also adds BLOCK_REVIEWED to the eligible-kinds WHERE clause (Chronik spec §02) so reviewed blocks appear in the activity feed. Five new integration tests cover combine-within-2h, split-at-boundary, no-hard-cap-on-long-session, never-rolls-up-comments/mentions, and the count/happenedAtUntil contract on both singletons and rollups. Part of #285. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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.AuditLogQueryRepository;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
@Transactional
|
||||
class AuditLogQueryRepositoryRolledUpTest {
|
||||
|
||||
static final UUID USER_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
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");
|
||||
|
||||
@Autowired AuditLogQueryRepository auditLogQueryRepository;
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
private NamedParameterJdbcTemplate named() {
|
||||
return new NamedParameterJdbcTemplate(jdbcTemplate);
|
||||
}
|
||||
|
||||
private void insertUserAndDocs() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
|
||||
USER_ID, "rollup-" + USER_ID + "@test.com");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief A', 'a.pdf', 'PLACEHOLDER')",
|
||||
DOC_ID);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief B', 'b.pdf', 'PLACEHOLDER')",
|
||||
OTHER_DOC_ID);
|
||||
}
|
||||
|
||||
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt) {
|
||||
MapSqlParameterSource params = new MapSqlParameterSource()
|
||||
.addValue("kind", kind)
|
||||
.addValue("actor", actorId)
|
||||
.addValue("doc", docId)
|
||||
.addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC));
|
||||
named().update(
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) "
|
||||
+ "VALUES (:kind, :actor, :doc, :t)",
|
||||
params);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_combines_same_actor_same_doc_within_2h() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T09:00:00Z");
|
||||
for (int i = 0; i < 20; i++) {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
|
||||
}
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
ActivityFeedRow row = rows.get(0);
|
||||
assertThat(row.getKind()).isEqualTo("TEXT_SAVED");
|
||||
assertThat(row.getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(row.getCount()).isEqualTo(20);
|
||||
assertThat(row.getHappenedAt()).isEqualTo(base);
|
||||
assertThat(row.getHappenedAtUntil()).isEqualTo(base.plusSeconds(19 * 480L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_splits_at_2h_boundary() {
|
||||
insertUserAndDocs();
|
||||
Instant sessionOneStart = Instant.parse("2026-04-20T08:00:00Z");
|
||||
Instant sessionOneLast = sessionOneStart.plusSeconds(600);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneStart);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneLast);
|
||||
Instant sessionTwoStart = sessionOneLast.plusSeconds(2L * 60L * 60L + 60L);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart.plusSeconds(300));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(2);
|
||||
assertThat(rows.get(0).getHappenedAt()).isEqualTo(sessionTwoStart);
|
||||
assertThat(rows.get(1).getCount()).isEqualTo(2);
|
||||
assertThat(rows.get(1).getHappenedAt()).isEqualTo(sessionOneStart);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_has_no_hard_cap_on_long_session() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T06:00:00Z");
|
||||
for (int i = 0; i < 30; i++) {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(i * 60L * 30L));
|
||||
}
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(30);
|
||||
assertThat(rows.get(0).getHappenedAt()).isEqualTo(base);
|
||||
assertThat(rows.get(0).getHappenedAtUntil()).isEqualTo(base.plusSeconds(29 * 60L * 30L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_never_rolls_up_COMMENT_ADDED_or_MENTION_CREATED() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(120));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(3);
|
||||
assertThat(rows).allSatisfy(r -> {
|
||||
assertThat(r.getKind()).isEqualTo("COMMENT_ADDED");
|
||||
assertThat(r.getCount()).isEqualTo(1);
|
||||
assertThat(r.getHappenedAtUntil()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_exposes_count_and_happenedAtUntil_on_singletons_and_rollups() {
|
||||
insertUserAndDocs();
|
||||
Instant rollupStart = Instant.parse("2026-04-20T11:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart);
|
||||
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.findDedupedActivityFeed(USER_ID.toString(), 40);
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows).anySatisfy(r -> {
|
||||
assertThat(r.getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(r.getCount()).isEqualTo(2);
|
||||
assertThat(r.getHappenedAt()).isEqualTo(rollupStart);
|
||||
assertThat(r.getHappenedAtUntil()).isEqualTo(rollupStart.plusSeconds(300));
|
||||
});
|
||||
assertThat(rows).anySatisfy(r -> {
|
||||
assertThat(r.getDocumentId()).isEqualTo(OTHER_DOC_ID);
|
||||
assertThat(r.getCount()).isEqualTo(1);
|
||||
assertThat(r.getHappenedAt()).isEqualTo(rollupStart.plusSeconds(900));
|
||||
assertThat(r.getHappenedAtUntil()).isNull();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user