diff --git a/docs/api-specs/adfit-api.md b/docs/api-specs/adfit-api.md index 46bc6b8..7b8824e 100644 --- a/docs/api-specs/adfit-api.md +++ b/docs/api-specs/adfit-api.md @@ -2,15 +2,23 @@ - `GET /api/v1/admin/adfit?from=YYYY-MM-DD&to=YYYY-MM-DD`: ADMIN 전용. 최대 366일. - `PUT /api/v1/admin/adfit/daily`: ADMIN 전용. `{date, unit, revenue, cost, costBasis}`. +- `GET` 응답에는 기존 수동 입력 단위 리포트(`source`, `units`, `days`)와 별도로 `account`가 포함된다. +- `account.status`: `NOT_CONFIGURED`, `CONNECTED`, `RECONNECT_REQUIRED`, `UNAVAILABLE`. +- `account.days[]`: `{date, revenue, ctr, ecpm, fillRate, winFillRate}`. AdFit 콘솔에 값이 없거나 누락된 날짜는 `null`이며 0으로 대체하지 않는다. +- `account.fetchedAt`: `CONNECTED`일 때 이번 관리자 조회에서 AdFit 응답을 성공적으로 파싱한 시각이다. 원천 데이터의 최종 집계 시각이나 배치 동기화 시각이 아니다. +- `account.revenue`: 조회 기간 중 실제 내려온 일별 수익 합계. 수익 데이터가 전부 `null`이면 `null`. +- `account.cost`, `account.roi`: AdFit 계정 자동 보고서가 광고 비용을 제공하지 않으므로 항상 `null`. +- 자동 보고서는 `ADFIT_SESSION_COOKIE` 또는 `picke.adfit.session-cookie`가 있을 때 AdFit 콘솔 계정 종합 일별 API를 조회한다. +- 세션 쿠키가 없으면 `NOT_CONFIGURED`, 로그인 만료·리다이렉트·HTML 로그인 응답이면 `RECONNECT_REQUIRED`, API 장애·스키마 불일치면 `UNAVAILABLE`. - `unit`: NATIVE_WIDE(홈·큐레이션·마이페이지 공유), BANNER(탐색), APP_TRANSITION(앱 시작). - `costBasis`: AD_OPERATIONS(광고 운영비), ACQUISITION(유입 광고비), SERVICE_OPERATIONS(서비스 운영비). - KRW 금액, 소수 둘째 자리까지, 음수 불가. 같은 날짜·단위는 수정. 미래 날짜 입력 불가. -- source=MANUAL_CONSOLE: AdFit 콘솔에서 확인한 예상 수익을 관리자가 입력한다. 자동 연동·확정 정산액이 아니다. +- source=MANUAL_CONSOLE: `units`와 `days`는 수동 입력 데이터다. 자동 계정 수익은 `account`만 사용한다. - 광고 단위는 로컬 Picke-iOS의 SDK 연결 기준이다. 개별 사용자에게 SDK가 선택한 이미지·광고주 소재를 재현하거나 실시간 노출을 보증하지 않는다. - 같은 단위를 여러 화면에서 사용하더라도 수익은 한 번만 합산한다. 비용도 단위별 배분액으로 입력하며 전체 운영비를 각 단위에 중복 입력하지 않는다. - 수익과 비용은 입력된 날짜들의 합계다. reportedDays/expectedDays로 부분 입력을 표시한다. 미입력은 null이며 0원이 아니다. - ROI = (수익 - 비용) / 비용 × 100. 모든 날짜가 입력되고 같은 비용 기준이며 비용이 양수일 때만 계산한다. 그 외 null. - 수익 0, 비용 양수인 정상 입력은 ROI -100%다. 미입력과 구분한다. -- 공식 공개 보고서 REST API는 확인하지 못했으므로 비공개 API를 추측하거나 관리자 브라우저에 인증 정보를 저장하지 않는다. +- AdFit 계정 자동 보고서는 콘솔 내부 API(`accountTotal/periodicIndicators`)를 사용한다. 공개 파트너 REST API가 아니므로 세션 만료 시 재연결이 필요하다. - 공식 참고: https://adfit.kakao.com/ , https://adfit.github.io/ - DB: `docs/db/20260910_create_adfit_daily_reports.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다. diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java new file mode 100644 index 0000000..e9fc417 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java @@ -0,0 +1,163 @@ +package com.swyp.picke.domain.admin.adfit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.net.URI; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Component +public class AdfitAccountReportClient { + private static final String REPORT_URL = + "https://adfit.kakao.com/api/v2/report/accountTotal/periodicIndicators"; + + private final String sessionCookie; + private final AdfitHttpTransport transport; + private final ObjectMapper objectMapper; + + @Autowired + public AdfitAccountReportClient( + @Value("${picke.adfit.session-cookie:${ADFIT_SESSION_COOKIE:}}") String sessionCookie, + AdfitHttpTransport transport) { + this(sessionCookie, transport, new ObjectMapper()); + } + + AdfitAccountReportClient(String sessionCookie, AdfitHttpTransport transport, ObjectMapper objectMapper) { + this.sessionCookie = sessionCookie; + this.transport = transport; + this.objectMapper = objectMapper; + } + + public AdfitReport.AccountReport fetch(LocalDate from, LocalDate to, long expectedDays) { + validateRange(from, to); + List emptyDays = nullDays(from, to); + if (!StringUtils.hasText(sessionCookie)) { + return report(AdfitAccountReportStatus.NOT_CONFIGURED, null, expectedDays, null, emptyDays); + } + + AdfitHttpResponse response = transport.get(uri(from, to), sessionCookie); + if (response.statusCode() == 401 || response.statusCode() == 403 || response.statusCode() == 419 + || response.isRedirect() + || looksLikeHtml(response.body())) { + return report(AdfitAccountReportStatus.RECONNECT_REQUIRED, null, expectedDays, null, emptyDays); + } + if (response.statusCode() < 200 || response.statusCode() >= 300 || !response.isJson()) { + return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays); + } + + try { + List days = parseDays(response.body(), from, to); + BigDecimal revenue = days.stream() + .map(AdfitReport.AccountDay::revenue) + .filter(value -> value != null) + .reduce(BigDecimal.ZERO, BigDecimal::add); + long reportedDays = days.stream().filter(day -> day.revenue() != null).count(); + return new AdfitReport.AccountReport(AdfitAccountReportStatus.CONNECTED, Instant.now(), + reportedDays, expectedDays, reportedDays == 0 ? null : revenue, null, null, days); + } catch (Exception e) { + log.warn("[AdFit] 계정 수익 보고서 응답 파싱 실패: {}", e.getClass().getSimpleName()); + return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays); + } + } + + private URI uri(LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(REPORT_URL) + .queryParam("dayType", "DAY") + .queryParam("startDate", from) + .queryParam("endDate", to) + .build() + .toUri(); + } + + private List parseDays(String body, LocalDate from, LocalDate to) throws Exception { + JsonNode root = objectMapper.readTree(body); + if (!root.isArray()) { + throw new IllegalArgumentException("AdFit report root must be an array."); + } + + Map byDate = new HashMap<>(); + for (JsonNode node : root) { + LocalDate date = LocalDate.parse(requiredText(node, "reportDate")); + if (date.isBefore(from) || date.isAfter(to)) { + throw new IllegalArgumentException("AdFit report date is out of range."); + } + if (!node.has("profit")) { + throw new IllegalArgumentException("AdFit report profit is missing."); + } + AdfitReport.AccountDay day = new AdfitReport.AccountDay(date, decimalOrNull(node, "profit"), + decimalOrNull(node, "ctr"), decimalOrNull(node, "ecpm"), + decimalOrNull(node, "fillRate"), decimalOrNull(node, "winFillRate")); + if (byDate.putIfAbsent(date, day) != null) { + throw new IllegalArgumentException("AdFit report date is duplicated."); + } + } + + List days = new ArrayList<>(); + LocalDate cursor = to; + while (!cursor.isBefore(from)) { + days.add(byDate.getOrDefault(cursor, + new AdfitReport.AccountDay(cursor, null, null, null, null, null))); + cursor = cursor.minusDays(1); + } + return days; + } + + private String requiredText(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull() || !value.isTextual()) { + throw new IllegalArgumentException("AdFit report " + field + " is invalid."); + } + return value.asText(); + } + + private BigDecimal decimalOrNull(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isNumber()) { + throw new IllegalArgumentException("AdFit report " + field + " is invalid."); + } + return value.decimalValue(); + } + + private List nullDays(LocalDate from, LocalDate to) { + List days = new ArrayList<>(); + LocalDate cursor = to; + while (!cursor.isBefore(from)) { + days.add(new AdfitReport.AccountDay(cursor, null, null, null, null, null)); + cursor = cursor.minusDays(1); + } + return days; + } + + private AdfitReport.AccountReport report(AdfitAccountReportStatus status, Instant fetchedAt, + long expectedDays, BigDecimal revenue, + List days) { + return new AdfitReport.AccountReport(status, fetchedAt, 0, expectedDays, revenue, null, null, days); + } + + private void validateRange(LocalDate from, LocalDate to) { + long expected = ChronoUnit.DAYS.between(from, to) + 1; + if (expected < 1 || expected > 366) { + throw new IllegalArgumentException("조회 기간은 1일부터 366일까지입니다."); + } + } + + private boolean looksLikeHtml(String body) { + return body != null && body.stripLeading().startsWith("<"); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java new file mode 100644 index 0000000..63aa7ec --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.adfit; + +public enum AdfitAccountReportStatus { + NOT_CONFIGURED, + CONNECTED, + RECONNECT_REQUIRED, + UNAVAILABLE +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java new file mode 100644 index 0000000..5fa96ad --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.util.List; +import java.util.Map; + +record AdfitHttpResponse(int statusCode, Map> headers, String body) { + boolean isRedirect() { + return statusCode >= 300 && statusCode < 400; + } + + boolean isJson() { + return headers.entrySet().stream() + .filter(entry -> "content-type".equalsIgnoreCase(entry.getKey())) + .flatMap(entry -> entry.getValue().stream()) + .anyMatch(value -> value.toLowerCase().contains("application/json")); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java new file mode 100644 index 0000000..c32c4d1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.net.URI; + +interface AdfitHttpTransport { + AdfitHttpResponse get(URI uri, String sessionCookie); +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java index 5b0f1ce..acbe2c0 100644 --- a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java @@ -1,14 +1,20 @@ package com.swyp.picke.domain.admin.adfit; import java.math.BigDecimal; +import java.time.Instant; import java.time.LocalDate; import java.util.List; public record AdfitReport(LocalDate from, LocalDate to, String source, - List units, List days) { + List units, List days, AccountReport account) { public record UnitReport(AdfitUnit unit, String name, List placements, String format, long reportedDays, long expectedDays, BigDecimal revenue, BigDecimal cost, BigDecimal roi) {} public record Day(LocalDate date, AdfitUnit unit, BigDecimal revenue, BigDecimal cost, AdfitCostBasis costBasis) {} + public record AccountReport(AdfitAccountReportStatus status, Instant fetchedAt, + long reportedDays, long expectedDays, BigDecimal revenue, + BigDecimal cost, BigDecimal roi, List days) {} + public record AccountDay(LocalDate date, BigDecimal revenue, BigDecimal ctr, BigDecimal ecpm, + BigDecimal fillRate, BigDecimal winFillRate) {} } diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java index 8e510a7..cbe5020 100644 --- a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java @@ -15,6 +15,7 @@ @RequiredArgsConstructor public class AdfitReportService { private final AdfitDailyRepository repository; + private final AdfitAccountReportClient accountReportClient; @Transactional public void save(AdfitDailyRequest request) { @@ -27,7 +28,6 @@ public void save(AdfitDailyRequest request) { repository.save(daily); } - @Transactional(readOnly = true) public AdfitReport report(LocalDate from, LocalDate to) { long expected = ChronoUnit.DAYS.between(from, to) + 1; if (expected < 1 || expected > 366) { @@ -49,8 +49,9 @@ public AdfitReport report(LocalDate from, LocalDate to) { return new AdfitReport.UnitReport(unit, unit.getDisplayName(), unit.getPlacements(), unit.getFormat(), entries.size(), expected, revenue, cost, roi); }).toList(); + AdfitReport.AccountReport account = accountReportClient.fetch(from, to, expected); return new AdfitReport(from, to, "MANUAL_CONSOLE", units, days.stream().map(day -> new AdfitReport.Day(day.getDate(), day.getUnit(), day.getRevenue(), day.getCost(), - day.getCostBasis())).toList()); + day.getCostBasis())).toList(), account); } } diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java index 31d258c..b17a300 100644 --- a/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java @@ -14,12 +14,12 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/admin/adfit") @PreAuthorize("hasRole('ADMIN')") -@Tag(name = "관리자 AdFit", description = "iOS 광고 단위 및 콘솔 수동 입력 수익·비용·ROI") +@Tag(name = "관리자 AdFit", description = "AdFit 계정 자동 수익 및 수동 입력 비용·ROI") public class AdminAdfitController { private final AdfitReportService service; @GetMapping - @Operation(summary = "AdFit 광고 단위와 기간별 수익·ROI", description = "미입력 금액 및 계산 불가 ROI는 null") + @Operation(summary = "AdFit 기간별 수익·ROI", description = "account는 AdFit 계정 자동 수익, units/days는 수동 입력 비용·ROI. 미입력·계산 불가는 null") public ApiResponse report( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java new file mode 100644 index 0000000..02c7405 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +class JavaNetAdfitHttpTransport implements AdfitHttpTransport { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(8); + + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + @Override + public AdfitHttpResponse get(URI uri, String sessionCookie) { + try { + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/json") + .header("Referer", "https://adfit.kakao.com/report") + .header("Cookie", sessionCookie) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return new AdfitHttpResponse(response.statusCode(), response.headers().map(), response.body()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("interrupted")), ""); + } catch (Exception e) { + return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("request_failed")), ""); + } + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClientTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClientTest.java new file mode 100644 index 0000000..4c02ef0 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClientTest.java @@ -0,0 +1,115 @@ +package com.swyp.picke.domain.admin.adfit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.net.URI; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class AdfitAccountReportClientTest { + private final LocalDate from = LocalDate.of(2026, 9, 8); + private final LocalDate to = LocalDate.of(2026, 9, 10); + + @Test void springCreatesClientWithoutConfiguredCredentials() { + try (var context = new org.springframework.context.annotation.AnnotationConfigApplicationContext()) { + context.registerBean(AdfitHttpTransport.class, + () -> new CapturingTransport(response(200, "application/json", "[]"))); + context.register(AdfitAccountReportClient.class); + context.refresh(); + assertThat(context.getBean(AdfitAccountReportClient.class)).isNotNull(); + } + } + + @Test void returnsNotConfiguredWithoutCookie() { + var client = client("", response(200, "application/json", "[]")); + var result = client.fetch(from, to, 3); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.NOT_CONFIGURED); + assertThat(result.days()).hasSize(3); + assertThat(result.days()).extracting(AdfitReport.AccountDay::revenue).containsOnlyNulls(); + } + + @Test void fetchesConsoleDailyRevenueWithIsoDateParameters() { + var transport = new CapturingTransport(response(200, "application/json", """ + [ + {"reportDate":"2026-09-10","profit":null,"ctr":1.5,"ecpm":2,"fillRate":3,"winFillRate":4}, + {"reportDate":"2026-09-09","profit":5}, + {"reportDate":"2026-09-08","profit":0} + ] + """)); + var client = new AdfitAccountReportClient("KAKAO=secret", transport, new ObjectMapper()); + var result = client.fetch(from, to, 3); + assertThat(transport.uri.toString()).contains("dayType=DAY", "startDate=2026-09-08", "endDate=2026-09-10"); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.CONNECTED); + assertThat(result.fetchedAt()).isNotNull(); + assertThat(result.reportedDays()).isEqualTo(2); + assertThat(result.revenue()).isEqualByComparingTo("5"); + assertThat(result.roi()).isNull(); + assertThat(result.days()).extracting(AdfitReport.AccountDay::date) + .containsExactly(to, LocalDate.of(2026, 9, 9), from); + assertThat(result.days().getFirst().revenue()).isNull(); + assertThat(result.days().getFirst().ctr()).isEqualByComparingTo("1.5"); + assertThat(result.days().get(1).revenue()).isEqualByComparingTo("5"); + assertThat(result.days().get(2).revenue()).isEqualByComparingTo("0"); + } + + @Test void treatsAuthFailuresRedirectsAndHtmlAsReconnectRequired() { + assertReconnectRequired(response(401, "application/json", "{}")); + assertReconnectRequired(response(419, "application/json", "{}")); + assertReconnectRequired(response(302, "text/plain", "")); + assertReconnectRequired(response(200, "text/html", "login")); + } + + @Test void rejectsUnavailableAndInvalidSchemaWithoutFabricatingRevenue() { + assertUnavailable(response(500, "application/json", "{}")); + assertUnavailable(response(200, "text/plain", "[]")); + assertUnavailable(response(200, "application/json", "{}")); + assertUnavailable(response(200, "application/json", "[{\"reportDate\":\"2026-09-10\"}]")); + assertUnavailable(response(200, "application/json", "[{\"reportDate\":\"2026-09-10\",\"profit\":\"5\"}]")); + assertUnavailable(response(200, "application/json", "[{\"reportDate\":\"2026-09-11\",\"profit\":5}]")); + assertUnavailable(response(200, "application/json", """ + [ + {"reportDate":"2026-09-10","profit":5}, + {"reportDate":"2026-09-10","profit":6} + ] + """)); + } + + private void assertReconnectRequired(AdfitHttpResponse response) { + var result = client("KAKAO=secret", response).fetch(from, to, 3); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.RECONNECT_REQUIRED); + assertThat(result.revenue()).isNull(); + } + + private void assertUnavailable(AdfitHttpResponse response) { + var result = client("KAKAO=secret", response).fetch(from, to, 3); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.UNAVAILABLE); + assertThat(result.revenue()).isNull(); + assertThat(result.days()).extracting(AdfitReport.AccountDay::revenue).containsOnlyNulls(); + } + + private AdfitAccountReportClient client(String cookie, AdfitHttpResponse response) { + return new AdfitAccountReportClient(cookie, new CapturingTransport(response), new ObjectMapper()); + } + + private AdfitHttpResponse response(int status, String contentType, String body) { + return new AdfitHttpResponse(status, Map.of("content-type", List.of(contentType)), body); + } + + private static class CapturingTransport implements AdfitHttpTransport { + private final AdfitHttpResponse response; + private URI uri; + + CapturingTransport(AdfitHttpResponse response) { + this.response = response; + } + + @Override + public AdfitHttpResponse get(URI uri, String sessionCookie) { + this.uri = uri; + return response; + } + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java index 3c1d0b9..614e976 100644 --- a/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java @@ -15,6 +15,7 @@ @ExtendWith(MockitoExtension.class) class AdfitReportServiceTest { @Mock AdfitDailyRepository repository; + @Mock AdfitAccountReportClient accountReportClient; @InjectMocks AdfitReportService service; private final LocalDate date = LocalDate.of(2026, 9, 1); @@ -28,8 +29,11 @@ private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBas when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))).thenReturn(List.of( day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS), day(date.plusDays(1), "100", "50", AdfitCostBasis.AD_OPERATIONS))); + when(accountReportClient.fetch(date, date.plusDays(1), 2)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 2)); var result = service.report(date, date.plusDays(1)); assertThat(result.units()).hasSize(3); + assertThat(result.account().status()).isEqualTo(AdfitAccountReportStatus.NOT_CONFIGURED); var unit = result.units().getFirst(); assertThat(unit.placements()).hasSize(3); assertThat(unit.revenue()).isEqualByComparingTo("300"); @@ -39,6 +43,7 @@ private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBas @Test void missingRevenueIsNotZero() { when(repository.findAllByDateBetweenOrderByDateDesc(date, date)).thenReturn(List.of()); + when(accountReportClient.fetch(date, date, 1)).thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 1)); var unit = service.report(date, date).units().getFirst(); assertThat(unit.revenue()).isNull(); assertThat(unit.cost()).isNull(); @@ -49,6 +54,8 @@ private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBas @Test void partialPeriodDoesNotReportRoi() { when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))) .thenReturn(List.of(day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS))); + when(accountReportClient.fetch(date, date.plusDays(1), 2)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 2)); var unit = service.report(date, date.plusDays(1)).units().getFirst(); assertThat(unit.revenue()).isEqualByComparingTo("200"); assertThat(unit.roi()).isNull(); @@ -59,6 +66,9 @@ private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBas when(repository.findAllByDateBetweenOrderByDateDesc(date, date)) .thenReturn(List.of(day(date, "100", "0", AdfitCostBasis.AD_OPERATIONS))) .thenReturn(List.of(day(date, "50", "100", AdfitCostBasis.AD_OPERATIONS))); + when(accountReportClient.fetch(date, date, 1)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 1)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 1)); assertThat(service.report(date, date).units().getFirst().roi()).isNull(); assertThat(service.report(date, date).units().getFirst().roi()).isEqualByComparingTo("-50"); } @@ -67,6 +77,8 @@ private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBas when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))).thenReturn(List.of( day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS), day(date.plusDays(1), "100", "50", AdfitCostBasis.ACQUISITION))); + when(accountReportClient.fetch(date, date.plusDays(1), 2)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 2)); assertThat(service.report(date, date.plusDays(1)).units().getFirst().roi()).isNull(); } @@ -80,6 +92,16 @@ private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBas assertThat(existing.getCostBasis()).isEqualTo(AdfitCostBasis.ACQUISITION); } + @Test void fetchesAutomaticAccountReportSeparatelyFromManualUnitReport() { + when(repository.findAllByDateBetweenOrderByDateDesc(date, date)).thenReturn(List.of()); + var account = account(AdfitAccountReportStatus.CONNECTED, 1); + when(accountReportClient.fetch(date, date, 1)).thenReturn(account); + var result = service.report(date, date); + assertThat(result.source()).isEqualTo("MANUAL_CONSOLE"); + assertThat(result.account()).isSameAs(account); + verify(accountReportClient).fetch(date, date, 1); + } + @Test void rejectsInvalidPeriodsAndFutureEntries() { assertThatThrownBy(() -> service.report(date.plusDays(1), date)).isInstanceOf(IllegalArgumentException.class); assertThatThrownBy(() -> service.report(date, date.plusDays(366))).isInstanceOf(IllegalArgumentException.class); @@ -87,5 +109,10 @@ private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBas AdfitUnit.BANNER, BigDecimal.ONE, BigDecimal.ONE, AdfitCostBasis.AD_OPERATIONS))) .isInstanceOf(IllegalArgumentException.class); verifyNoInteractions(repository); + verifyNoInteractions(accountReportClient); + } + + private AdfitReport.AccountReport account(AdfitAccountReportStatus status, long expectedDays) { + return new AdfitReport.AccountReport(status, null, 0, expectedDays, null, null, null, List.of()); } }