From 4281c770b0f55cb334185f676a834de6ade88919 Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 11 Aug 2026 23:18:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20PostgreSQL=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=C2=B7=EC=95=8C=EB=A6=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20500=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nullable cursor와 선택 필터를 포함한 고정 JPQL을 Criteria 쿼리로 변경 - 값이 전달된 조건만 동적으로 추가해 PostgreSQL SQLSTATE 42P18 방지 - 감사 이력의 정렬 및 cursor 페이지네이션 동작 유지 - 무필터 감사 이력 조회와 알림 cursor·미읽음 필터 회귀 테스트 추가 - H2 및 PostgreSQL 16 통합 테스트 검증 --- .../persistence/JpaAuditEventRepository.java | 79 +++++++++++++++---- .../SpringDataAuditEventJpaRepository.java | 41 ---------- .../JpaNotificationRepository.java | 36 ++++++++- .../SpringDataNotificationJpaRepository.java | 19 ----- .../ApprovalAuditIntegrationTest.java | 7 ++ .../NotificationSecurityIntegrationTest.java | 23 ++++++ 6 files changed, 127 insertions(+), 78 deletions(-) diff --git a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java index 1abc22fc..416d2e61 100644 --- a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java +++ b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java @@ -4,19 +4,30 @@ import com.fowoco.server.audit.application.port.AuditEventRepository; import com.fowoco.server.audit.domain.AuditEvent; import com.fowoco.server.audit.domain.AuditTargetType; +import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository public class JpaAuditEventRepository implements AuditEventRepository { private final SpringDataAuditEventJpaRepository repository; + private final EntityManager entityManager; - public JpaAuditEventRepository(SpringDataAuditEventJpaRepository repository) { + public JpaAuditEventRepository( + SpringDataAuditEventJpaRepository repository, + EntityManager entityManager + ) { this.repository = repository; + this.entityManager = entityManager; } @Override @@ -44,19 +55,57 @@ public List findTaskActivities(UUID companyId, UUID taskId) { @Override public List search(AuditSearchCriteria criteria) { - return repository.search( - criteria.companyId(), - criteria.actorType(), - criteria.action(), - criteria.targetType(), - criteria.targetId(), - criteria.traceId(), - criteria.createdFrom(), - criteria.createdTo(), - criteria.beforeCreatedAt(), - criteria.beforeAuditEventId(), - PageRequest.of(0, criteria.limit()) - ) + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(AuditEventJpaEntity.class); + Root event = query.from(AuditEventJpaEntity.class); + List predicates = new ArrayList<>(); + + predicates.add(builder.equal(event.get("companyId"), criteria.companyId())); + if (criteria.actorType() != null) { + predicates.add(builder.equal(event.get("actorType"), criteria.actorType())); + } + if (criteria.action() != null) { + predicates.add(builder.equal(event.get("action"), criteria.action())); + } + if (criteria.targetType() != null) { + predicates.add(builder.equal(event.get("targetType"), criteria.targetType())); + } + if (criteria.targetId() != null) { + predicates.add(builder.equal(event.get("targetId"), criteria.targetId())); + } + if (criteria.traceId() != null) { + predicates.add(builder.equal(event.get("traceId"), criteria.traceId())); + } + + var createdAt = event.get("createdAt"); + if (criteria.createdFrom() != null) { + predicates.add(builder.greaterThanOrEqualTo(createdAt, criteria.createdFrom())); + } + if (criteria.createdTo() != null) { + predicates.add(builder.lessThanOrEqualTo(createdAt, criteria.createdTo())); + } + if (criteria.beforeCreatedAt() != null) { + predicates.add(builder.or( + builder.lessThan(createdAt, criteria.beforeCreatedAt()), + builder.and( + builder.equal(createdAt, criteria.beforeCreatedAt()), + builder.lessThan( + event.get("auditEventId"), + criteria.beforeAuditEventId() + ) + ) + )); + } + + query.where(predicates.toArray(Predicate[]::new)); + query.orderBy( + builder.desc(createdAt), + builder.desc(event.get("auditEventId")) + ); + + return entityManager.createQuery(query) + .setMaxResults(criteria.limit()) + .getResultList() .stream() .map(AuditEventJpaEntity::toDomain) .toList(); diff --git a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/SpringDataAuditEventJpaRepository.java b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/SpringDataAuditEventJpaRepository.java index b2bc6294..1300f52d 100644 --- a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/SpringDataAuditEventJpaRepository.java +++ b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/SpringDataAuditEventJpaRepository.java @@ -1,15 +1,9 @@ package com.fowoco.server.audit.infrastructure.persistence; -import com.fowoco.server.audit.domain.ActorType; -import com.fowoco.server.audit.domain.AuditAction; import com.fowoco.server.audit.domain.AuditTargetType; -import java.time.Instant; import java.util.List; import java.util.UUID; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; interface SpringDataAuditEventJpaRepository extends JpaRepository { @@ -18,39 +12,4 @@ List findTop200ByCompanyIdAndTargetTypeAndTargetIdOrderByCr AuditTargetType targetType, UUID targetId ); - - @Query(""" - SELECT event - FROM AuditEventJpaEntity event - WHERE event.companyId = :companyId - AND (:actorType IS NULL OR event.actorType = :actorType) - AND (:action IS NULL OR event.action = :action) - AND (:targetType IS NULL OR event.targetType = :targetType) - AND (:targetId IS NULL OR event.targetId = :targetId) - AND (:traceId IS NULL OR event.traceId = :traceId) - AND (:createdFrom IS NULL OR event.createdAt >= :createdFrom) - AND (:createdTo IS NULL OR event.createdAt <= :createdTo) - AND ( - :beforeCreatedAt IS NULL - OR event.createdAt < :beforeCreatedAt - OR ( - event.createdAt = :beforeCreatedAt - AND event.auditEventId < :beforeAuditEventId - ) - ) - ORDER BY event.createdAt DESC, event.auditEventId DESC - """) - List search( - @Param("companyId") UUID companyId, - @Param("actorType") ActorType actorType, - @Param("action") AuditAction action, - @Param("targetType") AuditTargetType targetType, - @Param("targetId") UUID targetId, - @Param("traceId") String traceId, - @Param("createdFrom") Instant createdFrom, - @Param("createdTo") Instant createdTo, - @Param("beforeCreatedAt") Instant beforeCreatedAt, - @Param("beforeAuditEventId") UUID beforeAuditEventId, - Pageable pageable - ); } diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java index 077224b6..e8660395 100644 --- a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JpaNotificationRepository.java @@ -2,20 +2,30 @@ import com.fowoco.server.notification.application.port.NotificationRepository; import com.fowoco.server.notification.domain.Notification; +import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository public class JpaNotificationRepository implements NotificationRepository { private final SpringDataNotificationJpaRepository repository; + private final EntityManager entityManager; - public JpaNotificationRepository(SpringDataNotificationJpaRepository repository) { + public JpaNotificationRepository( + SpringDataNotificationJpaRepository repository, + EntityManager entityManager + ) { this.repository = repository; + this.entityManager = entityManager; } @Override @@ -39,7 +49,27 @@ public Optional findByIdAndCompanyId(UUID notificationId, UUID com @Override public List findPage(UUID companyId, UUID userId, boolean unreadOnly, Instant cursor, int size) { - return repository.findPage(companyId, userId, unreadOnly, cursor, PageRequest.of(0, size)).stream() + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(NotificationJpaEntity.class); + Root notification = query.from(NotificationJpaEntity.class); + List predicates = new ArrayList<>(); + + predicates.add(builder.equal(notification.get("companyId"), companyId)); + predicates.add(builder.equal(notification.get("userId"), userId)); + if (unreadOnly) { + predicates.add(builder.isFalse(notification.get("read"))); + } + if (cursor != null) { + predicates.add(builder.lessThan(notification.get("occurredAt"), cursor)); + } + + query.where(predicates.toArray(Predicate[]::new)); + query.orderBy(builder.desc(notification.get("occurredAt"))); + + return entityManager.createQuery(query) + .setMaxResults(size) + .getResultList() + .stream() .map(NotificationJpaEntity::toDomain) .toList(); } diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java index 369e02db..649cc07c 100644 --- a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/SpringDataNotificationJpaRepository.java @@ -1,8 +1,6 @@ package com.fowoco.server.notification.infrastructure.persistence; -import java.time.Instant; import java.util.UUID; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -20,22 +18,5 @@ java.util.Optional findByIdAndCompanyId( @Param("companyId") UUID companyId ); - @Query(""" - SELECT n - FROM NotificationJpaEntity n - WHERE n.companyId = :companyId - AND n.userId = :userId - AND (:unreadOnly = false OR n.read = false) - AND (:cursor IS NULL OR n.occurredAt < :cursor) - ORDER BY n.occurredAt DESC - """) - java.util.List findPage( - @Param("companyId") UUID companyId, - @Param("userId") UUID userId, - @Param("unreadOnly") boolean unreadOnly, - @Param("cursor") Instant cursor, - Pageable pageable - ); - long countByCompanyIdAndUserIdAndReadFalse(UUID companyId, UUID userId); } diff --git a/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java b/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java index bcfe395b..1762fb84 100644 --- a/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java +++ b/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java @@ -180,6 +180,13 @@ void approvedTaskCanBeSubmittedEvidencedCompletedAndAudited() throws Exception { .doesNotContain("file-ref-001"); String adminToken = accessToken(login(ADMIN_A_EMAIL)); + HttpResponse unfilteredAuditPage = authorizedGet( + "/api/v1/audit-events?limit=100", + adminToken + ); + assertThat(unfilteredAuditPage.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(unfilteredAuditPage.body(), "$.items")).isNotEmpty(); + HttpResponse auditPage = authorizedGet( "/api/v1/audit-events?target_type=TASK&target_id=" + TASK_A + "&limit=2", adminToken diff --git a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java index 0e73d77e..f45e569d 100644 --- a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java @@ -186,6 +186,29 @@ void hasNextIsTrueWhenMoreItemsRemain() throws Exception { assertThat(JsonPath.read(response.body(), "$.has_next")).isTrue(); } + @Test + void cursorAndUnreadOnlyFiltersCanBeCombined() throws Exception { + Instant cursor = Instant.parse("2026-08-11T02:00:00Z"); + insertNotification(COMPANY_A, HR_A, "TASK", false, cursor.minusSeconds(1)); + insertNotification(COMPANY_A, HR_A, "TASK", true, cursor.minusSeconds(2)); + insertNotification(COMPANY_A, HR_A, "TASK", false, cursor.plusSeconds(1)); + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse page = authorizedGet( + "/api/v1/notifications?cursor=" + cursor, + accessToken + ); + HttpResponse unreadPage = authorizedGet( + "/api/v1/notifications?unreadOnly=true&cursor=" + cursor, + accessToken + ); + + assertThat(page.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(page.body(), "$.items")).hasSize(2); + assertThat(unreadPage.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(unreadPage.body(), "$.items")).hasSize(1); + } + private UUID insertNotification(UUID companyId, String targetType, boolean read, Instant occurredAt) { return insertNotification(companyId, HR_A, targetType, read, occurredAt); } From f1ec683241cbe07527e8990a1b3411e548b29aff Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 11 Aug 2026 23:50:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20PostgreSQL=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=C2=B7=EC=95=8C=EB=A6=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI에서 활성화되는 PostgreSqlAuditNotificationQueryIntegrationTest를 추가했습니다. DynamicPropertySource로 POSTGRES_TEST_* 환경 변수를 datasource에 연결하고, pg_catalog.version() 검증을 통해 실제 PostgreSQL에서 실행되는 것도 명시적으로 확인합니다. 해당 테스트에서 아래 요청이 200을 반환하고 seeded 데이터를 정상 조회하는지 검증했습니다. - GET /api/v1/audit-events?limit=100 - GET /api/v1/notifications?size=20 로컬 psql DB에서도 테스트 통과를 확인했습니다. --- ...AuditNotificationQueryIntegrationTest.java | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/test/java/com/fowoco/server/PostgreSqlAuditNotificationQueryIntegrationTest.java diff --git a/src/test/java/com/fowoco/server/PostgreSqlAuditNotificationQueryIntegrationTest.java b/src/test/java/com/fowoco/server/PostgreSqlAuditNotificationQueryIntegrationTest.java new file mode 100644 index 00000000..18ab6ae8 --- /dev/null +++ b/src/test/java/com/fowoco/server/PostgreSqlAuditNotificationQueryIntegrationTest.java @@ -0,0 +1,238 @@ +package com.fowoco.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class PostgreSqlAuditNotificationQueryIntegrationTest { + + private static final UUID COMPANY_ID = + UUID.fromString("72000000-0000-0000-0000-000000000001"); + private static final UUID ADMIN_ID = + UUID.fromString("72000000-0000-0000-0000-000000000002"); + private static final UUID AUDIT_EVENT_ID = + UUID.fromString("72000000-0000-0000-0000-000000000003"); + private static final UUID NOTIFICATION_ID = + UUID.fromString("72000000-0000-0000-0000-000000000004"); + private static final UUID TARGET_ID = + UUID.fromString("72000000-0000-0000-0000-000000000005"); + private static final String EMAIL = "postgres.query-regression@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final Instant OCCURRED_AT = Instant.parse("2026-08-11T02:00:00Z"); + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private PostgreSqlRlsTestLock databaseLock; + + @DynamicPropertySource + static void usePostgreSql(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> required("POSTGRES_TEST_URL")); + registry.add("spring.datasource.username", () -> required("POSTGRES_TEST_USERNAME")); + registry.add("spring.datasource.password", () -> required("POSTGRES_TEST_PASSWORD")); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add( + "spring.flyway.locations", + () -> "classpath:db/migration,classpath:db/migration-postgresql" + ); + } + + @BeforeAll + void acquireDatabaseLock() throws Exception { + databaseLock = PostgreSqlRlsTestLock.acquire( + required("POSTGRES_TEST_URL"), + required("POSTGRES_TEST_USERNAME"), + required("POSTGRES_TEST_PASSWORD") + ); + } + + @AfterAll + void releaseDatabaseLock() throws Exception { + if (databaseLock != null) { + databaseLock.close(); + databaseLock = null; + } + } + + @BeforeEach + void seedQueryData() { + cleanQueryData(); + Timestamp now = Timestamp.from(OCCURRED_AT); + jdbcTemplate.update( + """ + INSERT INTO company ( + company_id, name, status, created_at, updated_at, version + ) VALUES (?, 'PostgreSQL query regression', 'ACTIVE', ?, ?, 0) + """, + COMPANY_ID, + now, + now + ); + jdbcTemplate.update( + "INSERT INTO company_settings (company_id) VALUES (?)", + COMPANY_ID + ); + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'ADMIN', 'ACTIVE', ?, ?, 0) + """, + ADMIN_ID, + COMPANY_ID, + EMAIL, + EMAIL, + passwordEncoder.encode(PASSWORD), + now, + now + ); + jdbcTemplate.update( + """ + INSERT INTO audit_event ( + audit_event_id, company_id, actor_type, actor_id, user_role, + action, target_type, target_id, request_id, + event_version, change_summary, created_at + ) VALUES (?, ?, 'HR_USER', ?, 'ADMIN', 'TASK_CREATED', 'TASK', ?, ?, '1', ?, ?) + """, + AUDIT_EVENT_ID, + COMPANY_ID, + ADMIN_ID, + TARGET_ID, + "postgres-query-regression", + "PostgreSQL 감사 조회 회귀 검증", + now + ); + jdbcTemplate.update( + """ + INSERT INTO notification ( + notification_id, company_id, user_id, target_type, target_id, + route, title, is_read, occurred_at, created_at + ) VALUES (?, ?, ?, 'TASK', ?, ?, ?, false, ?, ?) + """, + NOTIFICATION_ID, + COMPANY_ID, + ADMIN_ID, + TARGET_ID, + "/tasks/" + TARGET_ID, + "PostgreSQL 알림 조회 회귀 검증", + now, + now + ); + } + + @AfterEach + void cleanUpQueryData() { + cleanQueryData(); + } + + @Test + void nullableFiltersDoNotBreakAuditAndNotificationHttpQueries() throws Exception { + assertThat(jdbcTemplate.queryForObject( + "SELECT pg_catalog.version()", + String.class + )).startsWith("PostgreSQL"); + String accessToken = accessToken(login()); + + HttpResponse auditResponse = authorizedGet( + "/api/v1/audit-events?limit=100", + accessToken + ); + HttpResponse notificationResponse = authorizedGet( + "/api/v1/notifications?size=20", + accessToken + ); + + assertThat(auditResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read( + auditResponse.body(), + "$.items[*].audit_event_id" + )).contains(AUDIT_EVENT_ID.toString()); + assertThat(notificationResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read( + notificationResponse.body(), + "$.items[*].id" + )).contains(NOTIFICATION_ID.toString()); + } + + private void cleanQueryData() { + jdbcTemplate.update("DELETE FROM notification WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update("DELETE FROM audit_event WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update("DELETE FROM refresh_token WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update("DELETE FROM user_account WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update("DELETE FROM company_settings WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update("DELETE FROM company WHERE company_id = ?", COMPANY_ID); + } + + private HttpResponse login() throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"email":"%s","password":"%s"} + """.formatted(EMAIL, PASSWORD))) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private HttpResponse authorizedGet(String path, String accessToken) + throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private static String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " environment variable is required."); + } + return value; + } +}