From 6dfeed5844199c9ca5b0fbf9a5817afbc194a4f2 Mon Sep 17 00:00:00 2001 From: CSJ Date: Thu, 30 Jul 2026 14:06:52 +0900 Subject: [PATCH] fix: qualify PM Inbox timestamps in PostgreSQL upsert --- archiveos-ai/build.gradle | 25 +++ .../ai/managed/ManagedSystemsRepository.java | 22 ++- ...ManagedSystemsPostgresIntegrationTest.java | 157 ++++++++++++++++++ 3 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 archiveos-ai/src/postgresIntegrationTest/java/com/archiveos/ai/managed/ManagedSystemsPostgresIntegrationTest.java diff --git a/archiveos-ai/build.gradle b/archiveos-ai/build.gradle index c3cd2e9..79ca567 100644 --- a/archiveos-ai/build.gradle +++ b/archiveos-ai/build.gradle @@ -42,3 +42,28 @@ dependencies { tasks.named("test") { useJUnitPlatform() } + +sourceSets { + postgresIntegrationTest { + java.srcDir "src/postgresIntegrationTest/java" + resources.srcDir "src/postgresIntegrationTest/resources" + compileClasspath += sourceSets.main.output + configurations.testRuntimeClasspath + runtimeClasspath += output + compileClasspath + } +} + +configurations { + postgresIntegrationTestImplementation.extendsFrom testImplementation + postgresIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly +} + +tasks.register("postgresIntegrationTest", Test) { + description = "Runs PostgreSQL 16 integration tests. Requires -Darchiveos.pgtest.url." + group = "verification" + testClassesDirs = sourceSets.postgresIntegrationTest.output.classesDirs + classpath = sourceSets.postgresIntegrationTest.runtimeClasspath + useJUnitPlatform() + ["archiveos.pgtest.url", "archiveos.pgtest.user", "archiveos.pgtest.password"].each { key -> + if (System.getProperty(key) != null) systemProperty key, System.getProperty(key) + } +} diff --git a/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsRepository.java b/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsRepository.java index 8e7df40..c90770f 100644 --- a/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsRepository.java +++ b/archiveos-ai/src/main/java/com/archiveos/ai/managed/ManagedSystemsRepository.java @@ -104,20 +104,26 @@ public Map inboxState(String id) { public Map updateInboxState(String id, String status, Map metadata) { ensureInboxStateTable(); - String acknowledged = "acknowledged".equals(status) ? "now()" : "acknowledged_at"; - String resolved = "resolved".equals(status) ? "now()" : "resolved_at"; return jdbc.queryForObject(""" - insert into public.pm_inbox_item_states(id, status, acknowledged_at, resolved_at, metadata) + insert into public.pm_inbox_item_states as current_state (id, status, acknowledged_at, resolved_at, metadata) values (?, ?, case when ? = 'acknowledged' then now() else null end, case when ? = 'resolved' then now() else null end, ?::jsonb) on conflict (id) do update set status = excluded.status, - acknowledged_at = %s, - resolved_at = %s, + acknowledged_at = case + when excluded.status = 'acknowledged' then now() + else current_state.acknowledged_at + end, + resolved_at = case + when excluded.status = 'resolved' then now() + else current_state.resolved_at + end, updated_at = now(), - metadata = pm_inbox_item_states.metadata || excluded.metadata - returning id, status, acknowledged_at, resolved_at, updated_at, metadata::text as metadata_json - """.formatted(acknowledged, resolved), this::inboxStateRow, id, status, status, status, Json.write(metadata == null ? Map.of() : metadata)); + metadata = current_state.metadata || excluded.metadata + returning current_state.id, current_state.status, current_state.acknowledged_at, + current_state.resolved_at, current_state.updated_at, + current_state.metadata::text as metadata_json + """, this::inboxStateRow, id, status, status, status, Json.write(metadata == null ? Map.of() : metadata)); } public void recordTimeline(String eventType, String status, String title, String summary, String systemId, String referenceId, Map metadata) { diff --git a/archiveos-ai/src/postgresIntegrationTest/java/com/archiveos/ai/managed/ManagedSystemsPostgresIntegrationTest.java b/archiveos-ai/src/postgresIntegrationTest/java/com/archiveos/ai/managed/ManagedSystemsPostgresIntegrationTest.java new file mode 100644 index 0000000..7944c7f --- /dev/null +++ b/archiveos-ai/src/postgresIntegrationTest/java/com/archiveos/ai/managed/ManagedSystemsPostgresIntegrationTest.java @@ -0,0 +1,157 @@ +package com.archiveos.ai.managed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import com.archiveos.ai.approval.ExternalApprovalRepository; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; + +class ManagedSystemsPostgresIntegrationTest { + private static final String INBOX_ID = "daily-report-report-1"; + + private JdbcTemplate jdbc; + private ManagedSystemsRepository repository; + private TransactionTemplate transactions; + + @BeforeEach + void setUp() { + String url = requiredProperty("archiveos.pgtest.url"); + DataSource dataSource = new DriverManagerDataSource(url, + System.getProperty("archiveos.pgtest.user", "postgres"), + System.getProperty("archiveos.pgtest.password", "")); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute(""" + create table if not exists public.pm_inbox_item_states ( + id text primary key, + status text not null check (status in ('open', 'acknowledged', 'resolved')), + acknowledged_at timestamptz, + resolved_at timestamptz, + updated_at timestamptz not null default now(), + metadata jsonb not null default '{}'::jsonb + ) + """); + jdbc.execute(""" + create table if not exists public.runtime_timeline ( + id bigserial primary key, + event_type text not null, + status text not null, + title text not null, + summary text, + project_id text, + source text, + reference_id text, + metadata jsonb not null default '{}'::jsonb + ) + """); + jdbc.update("delete from public.runtime_timeline"); + jdbc.update("delete from public.pm_inbox_item_states"); + repository = new ManagedSystemsRepository(jdbc); + transactions = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + } + + @AfterEach + void cleanUp() { + jdbc.update("delete from public.runtime_timeline"); + jdbc.update("delete from public.pm_inbox_item_states"); + } + + @Test + void acknowledgeAndResolveUseQualifiedPostgresUpsertAndPreserveTimestamps() { + ManagedSystemsService service = inboxService(); + + Map acknowledged = inTransaction(() -> service.acknowledge(INBOX_ID)); + String acknowledgedAt = (String) acknowledged.get("acknowledged_at"); + assertThat(acknowledged).containsEntry("status", "acknowledged"); + assertThat(acknowledgedAt).isNotNull(); + assertThat(acknowledged.get("resolved_at")).isNull(); + + Map resolved = inTransaction(() -> service.resolve(INBOX_ID)); + assertThat(resolved).containsEntry("status", "resolved"); + assertThat(resolved.get("acknowledged_at")).isEqualTo(acknowledgedAt); + assertThat(resolved.get("resolved_at")).isInstanceOf(String.class); + assertThat(timelineCount(INBOX_ID)).isEqualTo(2); + } + + @Test + void repeatedAcknowledgementAndResolutionAreIdempotentAndDoNotDuplicateTimeline() { + ManagedSystemsService service = inboxService(); + + Map acknowledged = inTransaction(() -> service.acknowledge(INBOX_ID)); + Map acknowledgedAgain = inTransaction(() -> service.acknowledge(INBOX_ID)); + assertThat(acknowledgedAgain).isEqualTo(acknowledged); + assertThat(timelineCount(INBOX_ID)).isEqualTo(1); + + Map resolved = inTransaction(() -> service.resolve(INBOX_ID)); + Map resolvedAgain = inTransaction(() -> service.resolve(INBOX_ID)); + Map afterResolvedAcknowledgement = inTransaction(() -> service.acknowledge(INBOX_ID)); + assertThat(resolvedAgain).isEqualTo(resolved); + assertThat(afterResolvedAcknowledgement).isEqualTo(resolved); + assertThat(timelineCount(INBOX_ID)).isEqualTo(2); + } + + @Test + void newResolutionSucceedsAndExistingMetadataIsMerged() { + Map resolved = repository.updateInboxState("new-resolution", "resolved", Map.of("newKey", "newValue")); + assertThat(resolved).containsEntry("status", "resolved"); + assertThat(resolved.get("acknowledged_at")).isNull(); + assertThat(resolved.get("resolved_at")).isInstanceOf(String.class); + + repository.updateInboxState("metadata-item", "acknowledged", Map.of("preserved", "value")); + Map merged = repository.updateInboxState("metadata-item", "resolved", Map.of("newKey", "newValue")); + @SuppressWarnings("unchecked") + Map metadata = (Map) merged.get("metadata"); + assertThat(metadata).containsEntry("preserved", "value").containsEntry("newKey", "newValue"); + } + + @Test + void failedTransactionLeavesNeitherInboxStateNorTimeline() { + assertThatThrownBy(() -> transactions.executeWithoutResult(status -> { + repository.updateInboxState("rollback-item", "acknowledged", Map.of("action", "test")); + repository.recordTimeline("approval", "success", "rollback", "rollback", "archive-os", "rollback-item", Map.of()); + throw new IllegalStateException("rollback"); + })).isInstanceOf(IllegalStateException.class); + + assertThat(repository.inboxState("rollback-item")).isNull(); + assertThat(timelineCount("rollback-item")).isZero(); + } + + private ManagedSystemsService inboxService() { + ExternalApprovalRepository approvals = mock(ExternalApprovalRepository.class); + ManagedSystemsService service = Mockito.spy(new ManagedSystemsService(repository, approvals)); + doReturn(List.of(Map.of("id", INBOX_ID))).when(service).pmInbox(); + return service; + } + + private Map inTransaction(java.util.concurrent.Callable> callback) { + return transactions.execute(status -> { + try { + return callback.call(); + } catch (Exception error) { + throw new IllegalStateException(error); + } + }); + } + + private long timelineCount(String referenceId) { + Long count = jdbc.queryForObject("select count(*) from public.runtime_timeline where reference_id = ?", Long.class, referenceId); + return count == null ? 0 : count; + } + + private String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) throw new IllegalStateException(name + " is required for PostgreSQL integration tests."); + return value; + } +}