From 4ef87d90f0a2d8a4a1d5f6290a287506e9f96462 Mon Sep 17 00:00:00 2001 From: arunderwood Date: Thu, 1 Jan 2026 22:39:31 -0800 Subject: [PATCH 1/2] feat(contests): cache full contest details from WA7BNM Add daily background job to scrape WA7BNM Contest Calendar detail pages and populate bands, modes, sponsor, officialRulesUrl, and extended metadata for contest series. Key changes: - Add ContestSeriesEntity to model WA7BNM's series concept - Add ContestSeriesClient HTML scraper with Jsoup for parsing - Add ContestSeriesRefreshTask scheduled daily at 4am UTC - Add wa7bnmRef field to ContestEntity for series linkage - Add database migration for contest_series tables - Configure Resilience4j circuit breaker for fault tolerance Rate limiting: 5 seconds between requests to be respectful to WA7BNM. Change detection: Uses "Revision Date" field to skip unchanged series. Closes #196 --- .../internal/ContestSeriesClient.java | 387 +++++++++++++++ .../internal/dto/ContestSeriesDto.java | 51 ++ .../scheduler/ContestRefreshService.java | 43 +- .../scheduler/ContestSeriesRefreshTask.java | 251 ++++++++++ .../persistence/entity/ContestEntity.java | 11 + .../entity/ContestSeriesEntity.java | 224 +++++++++ .../repository/ContestRepository.java | 18 + .../repository/ContestSeriesRepository.java | 26 + src/main/resources/application.yml | 13 +- .../migrations/008-contest-series-table.yaml | 139 ++++++ .../internal/ContestSeriesClientTest.java | 448 ++++++++++++++++++ .../scheduler/ContestRefreshServiceTest.java | 73 +++ .../ContestSeriesRefreshTaskTest.java | 254 ++++++++++ .../ContestSeriesEntityIntegrationTest.java | 433 +++++++++++++++++ 14 files changed, 2363 insertions(+), 8 deletions(-) create mode 100644 src/main/java/io/nextskip/contests/internal/ContestSeriesClient.java create mode 100644 src/main/java/io/nextskip/contests/internal/dto/ContestSeriesDto.java create mode 100644 src/main/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTask.java create mode 100644 src/main/java/io/nextskip/contests/persistence/entity/ContestSeriesEntity.java create mode 100644 src/main/java/io/nextskip/contests/persistence/repository/ContestSeriesRepository.java create mode 100644 src/main/resources/db/changelog/migrations/008-contest-series-table.yaml create mode 100644 src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java create mode 100644 src/test/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTaskTest.java create mode 100644 src/test/java/io/nextskip/contests/persistence/ContestSeriesEntityIntegrationTest.java diff --git a/src/main/java/io/nextskip/contests/internal/ContestSeriesClient.java b/src/main/java/io/nextskip/contests/internal/ContestSeriesClient.java new file mode 100644 index 00000000..356a94b8 --- /dev/null +++ b/src/main/java/io/nextskip/contests/internal/ContestSeriesClient.java @@ -0,0 +1,387 @@ +package io.nextskip.contests.internal; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryRegistry; +import io.nextskip.common.client.ExternalApiException; +import io.nextskip.common.model.FrequencyBand; +import io.nextskip.contests.internal.dto.ContestSeriesDto; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Client for scraping WA7BNM Contest Calendar detail pages (contestdetails.php?ref=N). */ +@Component +@SuppressWarnings({ + "PMD.AvoidCatchingGenericException", // Intentional: wrap parsing exceptions + "PMD.CyclomaticComplexity" // HTML scraper requires many parsing branches +}) +public class ContestSeriesClient { + + private static final Logger LOG = LoggerFactory.getLogger(ContestSeriesClient.class); + private static final String CLIENT_NAME = "contest-series"; + private static final String BASE_URL = "https://contestcalendar.com"; + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15); + + // Patterns for parsing page content + private static final Pattern REVISION_DATE_PATTERN = + Pattern.compile("Revision Date:\\s*([A-Za-z]+\\s+\\d{1,2},\\s*\\d{4})"); + private static final DateTimeFormatter REVISION_DATE_FORMAT = + DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.US); + + // WARC bands to exclude when "except WARC" is specified + private static final Set WARC_BANDS = Set.of( + FrequencyBand.BAND_60M, + FrequencyBand.BAND_30M, + FrequencyBand.BAND_17M, + FrequencyBand.BAND_12M + ); + + // All HF bands (excluding 6m and 2m) + private static final Set ALL_HF_BANDS = EnumSet.of( + FrequencyBand.BAND_160M, + FrequencyBand.BAND_80M, + FrequencyBand.BAND_60M, + FrequencyBand.BAND_40M, + FrequencyBand.BAND_30M, + FrequencyBand.BAND_20M, + FrequencyBand.BAND_17M, + FrequencyBand.BAND_15M, + FrequencyBand.BAND_12M, + FrequencyBand.BAND_10M + ); + + private final WebClient webClient; + private final CircuitBreaker circuitBreaker; + private final Retry retry; + + @Autowired + public ContestSeriesClient( + WebClient.Builder webClientBuilder, + CircuitBreakerRegistry circuitBreakerRegistry, + RetryRegistry retryRegistry) { + this(webClientBuilder, circuitBreakerRegistry, retryRegistry, BASE_URL); + } + + protected ContestSeriesClient( + WebClient.Builder webClientBuilder, + CircuitBreakerRegistry circuitBreakerRegistry, + RetryRegistry retryRegistry, + String baseUrl) { + this.webClient = webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(512 * 1024)) + .build(); + this.circuitBreaker = circuitBreakerRegistry.circuitBreaker(CLIENT_NAME); + this.retry = retryRegistry.retry(CLIENT_NAME); + } + + /** Fetches and parses a contest series detail page. */ + public ContestSeriesDto fetchSeriesDetails(String wa7bnmRef) { + Supplier decoratedFetch = () -> doFetchSeriesDetails(wa7bnmRef); + + Supplier retryWrapped = Retry.decorateSupplier(retry, decoratedFetch); + Supplier cbWrapped = CircuitBreaker.decorateSupplier(circuitBreaker, retryWrapped); + + try { + return cbWrapped.get(); + } catch (Exception e) { + LOG.error("Failed to fetch contest series ref={}: {}", wa7bnmRef, e.getMessage()); + throw new ExternalApiException(CLIENT_NAME, + "Failed to fetch contest series ref=" + wa7bnmRef + ": " + e.getMessage(), e); + } + } + + /** Fetches only the revision date for change detection. */ + public Optional fetchRevisionDate(String wa7bnmRef) { + try { + String html = fetchPageHtml(wa7bnmRef); + return parseRevisionDate(html); + } catch (Exception e) { + LOG.warn("Failed to fetch revision date for ref={}: {}", wa7bnmRef, e.getMessage()); + return Optional.empty(); + } + } + + private ContestSeriesDto doFetchSeriesDetails(String wa7bnmRef) { + try { + String html = fetchPageHtml(wa7bnmRef); + return parseContestDetails(wa7bnmRef, html); + + } catch (WebClientResponseException e) { + LOG.error("HTTP error fetching contest ref={}: {} {}", + wa7bnmRef, e.getStatusCode(), e.getStatusText()); + throw new ExternalApiException(CLIENT_NAME, + "HTTP " + e.getStatusCode() + " fetching ref=" + wa7bnmRef, e); + + } catch (WebClientRequestException e) { + LOG.error("Network error fetching contest ref={}", wa7bnmRef, e); + throw new ExternalApiException(CLIENT_NAME, + "Network error fetching ref=" + wa7bnmRef + ": " + e.getMessage(), e); + + } catch (Exception e) { + LOG.error("Unexpected error fetching contest ref={}", wa7bnmRef, e); + throw new ExternalApiException(CLIENT_NAME, + "Unexpected error fetching ref=" + wa7bnmRef + ": " + e.getMessage(), e); + } + } + + private String fetchPageHtml(String wa7bnmRef) { + LOG.debug("Fetching contest details page for ref={}", wa7bnmRef); + + String html = webClient.get() + .uri("/contestdetails.php?ref={ref}", wa7bnmRef) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .block(); + + if (html == null || html.isBlank()) { + throw new ExternalApiException(CLIENT_NAME, "Empty response for ref=" + wa7bnmRef); + } + + return html; + } + + /** Parses contest details from HTML content. Package-private for unit testing. */ + ContestSeriesDto parseContestDetails(String wa7bnmRef, String html) { + Document doc = Jsoup.parse(html); + + String name = parseContestName(doc); + Set bands = parseBands(doc); + Set modes = parseModes(doc); + String sponsor = parseSponsor(doc); + String rulesUrl = parseRulesUrl(doc); + String exchange = parseExchange(doc); + String cabrilloName = parseCabrilloName(doc); + LocalDate revisionDate = parseRevisionDate(html).orElse(null); + + LOG.debug("Parsed contest ref={}: name={}, bands={}, modes={}", + wa7bnmRef, name, bands.size(), modes.size()); + + return new ContestSeriesDto( + wa7bnmRef, + name, + bands, + modes, + sponsor, + rulesUrl, + exchange, + cabrilloName, + revisionDate + ); + } + + private String parseContestName(Document doc) { + Element h1 = doc.selectFirst("h1"); + if (h1 != null && !h1.text().isBlank()) { + return h1.text().trim(); + } + + String title = doc.title(); + if (title != null && !title.isBlank()) { + // Remove common suffixes like " - Contest Calendar" + int dashIndex = title.indexOf(" - "); + if (dashIndex > 0) { + return title.substring(0, dashIndex).trim(); + } + return title.trim(); + } + + return null; + } + + Set parseBands(Document doc) { + String bandsText = findFieldValue(doc, "Bands:"); + if (bandsText == null || bandsText.isBlank()) { + return Set.of(); + } + + String normalized = bandsText.toLowerCase(Locale.ROOT); + + // Check for "any" or "all" + if (normalized.contains("any") || normalized.matches("^all\\b.*")) { + Set result = EnumSet.copyOf(ALL_HF_BANDS); + // Check for WARC exclusion + if (normalized.contains("warc")) { + result.removeAll(WARC_BANDS); + } + return result; + } + + // Parse individual band mentions + Set bands = EnumSet.noneOf(FrequencyBand.class); + for (FrequencyBand band : FrequencyBand.values()) { + String bandName = band.getName().toLowerCase(Locale.ROOT); + // Match "20m" or "20 m" or just "20" followed by non-digit + if (normalized.contains(bandName) + || normalized.matches(".*\\b" + bandName.replace("m", "") + "\\s*m?\\b.*")) { + bands.add(band); + } + } + + return bands; + } + + Set parseModes(Document doc) { + String modeText = findFieldValue(doc, "Mode:"); + if (modeText == null || modeText.isBlank()) { + return Set.of(); + } + + String normalized = modeText.toLowerCase(Locale.ROOT); + Set modes = new HashSet<>(); + + // Check for "any" + if (normalized.contains("any")) { + modes.add("CW"); + modes.add("SSB"); + modes.add("Digital"); + return modes; + } + + // Check for specific modes + if (normalized.contains("cw")) { + modes.add("CW"); + } + if (normalized.contains("ssb") || normalized.contains("phone")) { + modes.add("SSB"); + } + if (normalized.contains("digital") || normalized.contains("rtty") + || normalized.contains("ft8") || normalized.contains("ft4") + || normalized.contains("psk")) { + modes.add("Digital"); + } + if (normalized.contains("fm")) { + modes.add("FM"); + } + if (normalized.contains("am")) { + modes.add("AM"); + } + + return modes; + } + + private String parseSponsor(Document doc) { + return findFieldValue(doc, "Sponsor:"); + } + + private String parseRulesUrl(Document doc) { + String href = findRulesLinkInTableCells(doc.select("td, th")); + if (href != null) return href; + for (Element link : doc.select("a[href]")) { + href = extractRulesHrefFromLink(link); + if (href != null) return href; + } + return null; + } + + private String findRulesLinkInTableCells(List cells) { + for (int i = 0; i < cells.size() - 1; i++) { + if (!cells.get(i).text().toLowerCase(Locale.ROOT).contains("find rules at")) continue; + Element link = cells.get(i + 1).selectFirst("a[href]"); + if (link != null && !link.attr("href").isBlank()) return link.attr("href"); + } + return null; + } + + private String extractRulesHrefFromLink(Element link) { + Element parent = link.parent(); + if (parent == null) return null; + String parentText = parent.text().toLowerCase(Locale.ROOT); + if (!parentText.contains("find rules at") && !parentText.contains("official rules")) return null; + String href = link.attr("abs:href"); + return href.isBlank() ? null : href; + } + + private String parseExchange(Document doc) { + return findFieldValue(doc, "Exchange:"); + } + + private String parseCabrilloName(Document doc) { + return findFieldValue(doc, "Cabrillo name:"); + } + + Optional parseRevisionDate(String html) { + Matcher matcher = REVISION_DATE_PATTERN.matcher(html); + if (matcher.find()) { + try { + return Optional.of(LocalDate.parse(matcher.group(1), REVISION_DATE_FORMAT)); + } catch (DateTimeParseException e) { + LOG.debug("Failed to parse revision date: {}", matcher.group(1)); + } + } + return Optional.empty(); + } + + private String findFieldValue(Document doc, String label) { + String labelLower = label.toLowerCase(Locale.ROOT); + String result = findValueInTableCells(doc.select("td, th"), labelLower); + if (result != null) return result; + result = findValueInDefinitionList(doc.select("dt"), labelLower); + if (result != null) return result; + return findValueInBoldText(doc.select("b, strong"), labelLower); + } + + private String findValueInTableCells(List cells, String labelLower) { + for (int i = 0; i < cells.size() - 1; i++) { + if (!cells.get(i).text().toLowerCase(Locale.ROOT).contains(labelLower)) continue; + String value = cells.get(i + 1).text().trim(); + if (!value.isBlank()) return value; + } + return null; + } + + private String findValueInDefinitionList(List dts, String labelLower) { + for (Element dt : dts) { + if (!dt.text().toLowerCase(Locale.ROOT).contains(labelLower)) continue; + Element dd = dt.nextElementSibling(); + if (dd != null && "dd".equals(dd.tagName())) { + String value = dd.text().trim(); + if (!value.isBlank()) return value; + } + } + return null; + } + + private String findValueInBoldText(List bolds, String labelLower) { + for (Element bold : bolds) { + if (!bold.text().toLowerCase(Locale.ROOT).contains(labelLower)) continue; + String value = extractValueAfterColon(bold); + if (value != null) return value; + } + return null; + } + + private String extractValueAfterColon(Element element) { + Element parent = element.parent(); + if (parent == null) return null; + String parentText = parent.text(); + int colonIndex = parentText.indexOf(':'); + if (colonIndex < 0 || colonIndex >= parentText.length() - 1) return null; + String value = parentText.substring(colonIndex + 1).trim(); + return value.isBlank() ? null : value; + } +} diff --git a/src/main/java/io/nextskip/contests/internal/dto/ContestSeriesDto.java b/src/main/java/io/nextskip/contests/internal/dto/ContestSeriesDto.java new file mode 100644 index 00000000..26a10043 --- /dev/null +++ b/src/main/java/io/nextskip/contests/internal/dto/ContestSeriesDto.java @@ -0,0 +1,51 @@ +package io.nextskip.contests.internal.dto; + +import io.nextskip.common.model.FrequencyBand; + +import java.time.LocalDate; +import java.util.Set; + +/** + * Internal DTO representing scraped contest series data from WA7BNM detail pages. + * + *

This DTO captures metadata parsed from contest detail pages at + * {@code https://contestcalendar.com/contestdetails.php?ref=N}. Fields include: + *

    + *
  • bands - permitted frequency bands (parsed from "Bands:" field)
  • + *
  • modes - permitted operating modes (parsed from "Mode:" field)
  • + *
  • sponsor - sponsoring organization (parsed from "Sponsor:" field)
  • + *
  • officialRulesUrl - link to official rules (parsed from "Find rules at:" link)
  • + *
  • exchange - expected exchange format (parsed from "Exchange:" field)
  • + *
  • cabrilloName - Cabrillo log identifier (parsed from "Cabrillo name:" field)
  • + *
  • revisionDate - page revision date for change detection
  • + *
+ * + * @param wa7bnmRef unique WA7BNM reference identifier (from URL ref parameter) + * @param name contest series name + * @param bands permitted frequency bands + * @param modes permitted operating modes + * @param sponsor sponsoring organization + * @param officialRulesUrl URL to official contest rules + * @param exchange expected exchange format + * @param cabrilloName Cabrillo log contest identifier + * @param revisionDate page revision date for change detection + */ +public record ContestSeriesDto( + String wa7bnmRef, + String name, + Set bands, + Set modes, + String sponsor, + String officialRulesUrl, + String exchange, + String cabrilloName, + LocalDate revisionDate +) { + /** + * Compact constructor for defensive copying of mutable collections. + */ + public ContestSeriesDto { + bands = bands != null ? Set.copyOf(bands) : Set.of(); + modes = modes != null ? Set.copyOf(modes) : Set.of(); + } +} diff --git a/src/main/java/io/nextskip/contests/internal/scheduler/ContestRefreshService.java b/src/main/java/io/nextskip/contests/internal/scheduler/ContestRefreshService.java index 053bc285..f0dc4d6e 100644 --- a/src/main/java/io/nextskip/contests/internal/scheduler/ContestRefreshService.java +++ b/src/main/java/io/nextskip/contests/internal/scheduler/ContestRefreshService.java @@ -18,6 +18,8 @@ import java.util.List; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Service for refreshing contest calendar data. @@ -35,6 +37,9 @@ public class ContestRefreshService extends AbstractRefreshService { private static final Logger LOG = LoggerFactory.getLogger(ContestRefreshService.class); private static final String SERVICE_NAME = "Contest"; + // Pattern to extract ref parameter from contest details URL + private static final Pattern WA7BNM_REF_PATTERN = Pattern.compile("[?&]ref=(\\d+)"); + private final ContestCalendarClient contestClient; private final ContestRepository repository; private final LoadingCache> contestsCache; @@ -63,10 +68,9 @@ protected void doRefresh() { // Fetch fresh data from API (returns ContestICalDto list) List dtos = contestClient.fetch(); - // Convert DTOs to domain model, then to entities + // Convert DTOs to entities with wa7bnmRef List entities = dtos.stream() - .map(this::convertToContest) - .map(ContestEntity::fromDomain) + .map(this::convertToEntity) .toList(); try { @@ -97,16 +101,17 @@ protected Logger getLog() { } /** - * Converts a ContestICalDto to a Contest domain model. + * Converts a ContestICalDto to a ContestEntity with wa7bnmRef. * *

Since the iCal feed doesn't include band/mode information, * these are set to empty sets. The calendar source URL is preserved. + * The WA7BNM reference is extracted from the details URL. * * @param dto the iCal DTO to convert - * @return the contest domain model + * @return the contest entity */ - private Contest convertToContest(ContestICalDto dto) { - return new Contest( + private ContestEntity convertToEntity(ContestICalDto dto) { + Contest contest = new Contest( dto.summary(), dto.startTime(), dto.endTime(), @@ -116,5 +121,29 @@ private Contest convertToContest(ContestICalDto dto) { dto.detailsUrl(), // calendarSourceUrl null // officialRulesUrl not available from iCal ); + + ContestEntity entity = ContestEntity.fromDomain(contest); + entity.setWa7bnmRef(extractWa7bnmRef(dto.detailsUrl())); + return entity; + } + + /** + * Extracts the WA7BNM reference from a contest details URL. + * + *

Parses URLs like {@code https://contestcalendar.com/contestdetails.php?ref=8} + * and returns the ref parameter value ("8"). + * + * @param url the contest details URL + * @return the ref parameter value, or null if not found + */ + String extractWa7bnmRef(String url) { + if (url == null || url.isBlank()) { + return null; + } + Matcher matcher = WA7BNM_REF_PATTERN.matcher(url); + if (matcher.find()) { + return matcher.group(1); + } + return null; } } diff --git a/src/main/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTask.java b/src/main/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTask.java new file mode 100644 index 00000000..35b15578 --- /dev/null +++ b/src/main/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTask.java @@ -0,0 +1,251 @@ +package io.nextskip.contests.internal.scheduler; + +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.kagkarlsson.scheduler.task.helper.RecurringTask; +import com.github.kagkarlsson.scheduler.task.helper.Tasks; +import com.github.kagkarlsson.scheduler.task.schedule.Daily; +import io.nextskip.common.config.CacheConfig; +import io.nextskip.common.scheduler.DataRefreshException; +import io.nextskip.contests.internal.ContestSeriesClient; +import io.nextskip.contests.internal.dto.ContestSeriesDto; +import io.nextskip.contests.model.Contest; +import io.nextskip.contests.persistence.entity.ContestEntity; +import io.nextskip.contests.persistence.entity.ContestSeriesEntity; +import io.nextskip.contests.persistence.repository.ContestRepository; +import io.nextskip.contests.persistence.repository.ContestSeriesRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +/** + * Recurring task for enriching contests with series metadata from WA7BNM detail pages. + * + *

Scrapes contest detail pages from WA7BNM Contest Calendar to populate + * bands, modes, sponsor, official rules URL, and other metadata. Uses a + * ContestSeries entity to cache scraped data and avoid redundant scraping. + * + *

The task runs daily at 4am UTC (off-peak hours). For each unique series + * reference in upcoming contests, it: + *

    + *
  1. Checks if the series already exists and if revision date has changed
  2. + *
  3. If changed or new, scrapes the full page and saves the series
  4. + *
  5. Copies series metadata to all contest occurrences with that reference
  6. + *
+ * + *

Rate limiting is applied between requests to respect the source website. + */ +@Configuration +public class ContestSeriesRefreshTask { + + private static final Logger LOG = LoggerFactory.getLogger(ContestSeriesRefreshTask.class); + private static final String TASK_NAME = "contest-series-refresh"; + + /** + * Creates the recurring task bean for contest series data refresh. + * + * @param seriesClient the contest series scraper client + * @param contestRepository the contest repository + * @param seriesRepository the contest series repository + * @param contestsCache the LoadingCache to refresh after updates + * @param rateLimitSeconds seconds to wait between scrape requests + * @return the configured recurring task + */ + @Bean + public RecurringTask contestSeriesRecurringTask( + ContestSeriesClient seriesClient, + ContestRepository contestRepository, + ContestSeriesRepository seriesRepository, + LoadingCache> contestsCache, + @Value("${nextskip.contests.series.rate-limit-seconds:5}") int rateLimitSeconds) { + + return Tasks.recurring(TASK_NAME, new Daily(ZoneOffset.UTC, LocalTime.of(4, 0))) + .execute((taskInstance, executionContext) -> + executeRefresh(seriesClient, contestRepository, seriesRepository, + contestsCache, rateLimitSeconds)); + } + + /** + * Executes the contest series data refresh. + * + *

For each unique series in upcoming contests, checks if the series + * needs to be scraped (new or revision date changed), scrapes if needed, + * and copies metadata to contest occurrences. + * + *

This method is package-private to allow testing. + * + * @param seriesClient the contest series scraper client + * @param contestRepository the contest repository + * @param seriesRepository the contest series repository + * @param contestsCache the cache to refresh + * @param rateLimitSeconds seconds to wait between requests + */ + @Transactional + @SuppressWarnings("PMD.AvoidCatchingGenericException") // API client can throw various exceptions + void executeRefresh( + ContestSeriesClient seriesClient, + ContestRepository contestRepository, + ContestSeriesRepository seriesRepository, + LoadingCache> contestsCache, + int rateLimitSeconds) { + + LOG.info("Starting contest series refresh task"); + + try { + // Find unique series references for contests ending after now + List refs = contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(Instant.now()); + + LOG.debug("Found {} unique series references to process", refs.size()); + + int scraped = 0; + int skipped = 0; + int errors = 0; + + for (String ref : refs) { + try { + boolean wasScraped = processSeriesRef(ref, seriesClient, contestRepository, + seriesRepository); + if (wasScraped) { + scraped++; + // Rate limit between scrapes + sleepBetweenRequests(rateLimitSeconds); + } else { + skipped++; + } + } catch (Exception e) { + LOG.warn("Failed to process series ref={}: {}", ref, e.getMessage()); + errors++; + } + } + + // Trigger async cache refresh + contestsCache.refresh(CacheConfig.CACHE_KEY); + + LOG.info("Contest series refresh complete: scraped={}, skipped={}, errors={}", + scraped, skipped, errors); + + } catch (Exception e) { + LOG.error("Contest series refresh failed: {}", e.getMessage(), e); + throw new DataRefreshException("Contest series refresh failed", e); + } + } + + /** + * Processes a single series reference. + * + *

Checks if the series needs to be scraped by comparing revision dates. + * If scraping is needed, fetches the full page, saves the series, and + * copies metadata to all contest occurrences. + * + * @param ref the WA7BNM reference + * @param seriesClient the scraper client + * @param contestRepository the contest repository + * @param seriesRepository the series repository + * @return true if the series was scraped, false if skipped + */ + private boolean processSeriesRef( + String ref, + ContestSeriesClient seriesClient, + ContestRepository contestRepository, + ContestSeriesRepository seriesRepository) { + + LOG.debug("Processing series ref={}", ref); + + // Check existing series for change detection + Optional existing = seriesRepository.findByWa7bnmRef(ref); + LocalDate existingRevisionDate = existing.map(ContestSeriesEntity::getRevisionDate).orElse(null); + + // Fetch current revision date from page (lightweight check) + Optional currentRevisionDate = seriesClient.fetchRevisionDate(ref); + + // Skip if revision date unchanged + if (existingRevisionDate != null && currentRevisionDate.isPresent() + && existingRevisionDate.equals(currentRevisionDate.get())) { + LOG.debug("Series ref={} unchanged (revision date: {})", ref, existingRevisionDate); + return false; + } + + // Need to scrape - fetch full details + LOG.debug("Scraping series ref={} (revision date changed: {} -> {})", + ref, existingRevisionDate, currentRevisionDate.orElse(null)); + + ContestSeriesDto dto = seriesClient.fetchSeriesDetails(ref); + + // Save or update series entity + ContestSeriesEntity entity = existing.orElseGet(ContestSeriesRefreshTask::createNewSeriesEntity); + updateSeriesEntity(entity, dto); + seriesRepository.save(entity); + + // Copy metadata to all contest occurrences + copySeriesDataToContests(ref, entity, contestRepository); + + LOG.debug("Scraped and saved series ref={}", ref); + return true; + } + + /** + * Factory method to create a new ContestSeriesEntity. + * Required because the default constructor is protected (JPA-only). + */ + private static ContestSeriesEntity createNewSeriesEntity() { + return new ContestSeriesEntity( + null, null, null, null, null, null, null, null, null, null); + } + + /** + * Updates a ContestSeriesEntity from a DTO. + */ + private void updateSeriesEntity(ContestSeriesEntity entity, ContestSeriesDto dto) { + entity.setWa7bnmRef(dto.wa7bnmRef()); + entity.setName(dto.name()); + entity.setBands(dto.bands()); + entity.setModes(dto.modes()); + entity.setSponsor(dto.sponsor()); + entity.setOfficialRulesUrl(dto.officialRulesUrl()); + entity.setExchange(dto.exchange()); + entity.setCabrilloName(dto.cabrilloName()); + entity.setRevisionDate(dto.revisionDate()); + entity.setLastScrapedAt(Instant.now()); + } + + /** + * Copies series metadata to all contest entities with the given reference. + */ + private void copySeriesDataToContests(String ref, ContestSeriesEntity series, + ContestRepository contestRepository) { + List contests = contestRepository.findByWa7bnmRef(ref); + + for (ContestEntity contest : contests) { + contest.setBands(series.getBands()); + contest.setModes(series.getModes()); + contest.setSponsor(series.getSponsor()); + contest.setOfficialRulesUrl(series.getOfficialRulesUrl()); + } + + contestRepository.saveAll(contests); + LOG.debug("Copied series data to {} contest occurrences for ref={}", contests.size(), ref); + } + + /** + * Sleeps between requests for rate limiting. + */ + @SuppressWarnings("PMD.AvoidThrowingRawExceptionTypes") // InterruptedException requires re-interrupt + private void sleepBetweenRequests(int seconds) { + try { + Thread.sleep(Duration.ofSeconds(seconds).toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during rate limit sleep", e); + } + } +} diff --git a/src/main/java/io/nextskip/contests/persistence/entity/ContestEntity.java b/src/main/java/io/nextskip/contests/persistence/entity/ContestEntity.java index 11ff9246..97ddaea2 100644 --- a/src/main/java/io/nextskip/contests/persistence/entity/ContestEntity.java +++ b/src/main/java/io/nextskip/contests/persistence/entity/ContestEntity.java @@ -77,6 +77,9 @@ public class ContestEntity { @Column(name = "official_rules_url", length = 500) private String officialRulesUrl; + @Column(name = "wa7bnm_ref", length = 20) + private String wa7bnmRef; + /** * Default constructor required by JPA. */ @@ -195,6 +198,10 @@ public String getOfficialRulesUrl() { return officialRulesUrl; } + public String getWa7bnmRef() { + return wa7bnmRef; + } + // Setters (for JPA) public void setName(String name) { @@ -228,4 +235,8 @@ public void setCalendarSourceUrl(String calendarSourceUrl) { public void setOfficialRulesUrl(String officialRulesUrl) { this.officialRulesUrl = officialRulesUrl; } + + public void setWa7bnmRef(String wa7bnmRef) { + this.wa7bnmRef = wa7bnmRef; + } } diff --git a/src/main/java/io/nextskip/contests/persistence/entity/ContestSeriesEntity.java b/src/main/java/io/nextskip/contests/persistence/entity/ContestSeriesEntity.java new file mode 100644 index 00000000..ff461fb4 --- /dev/null +++ b/src/main/java/io/nextskip/contests/persistence/entity/ContestSeriesEntity.java @@ -0,0 +1,224 @@ +package io.nextskip.contests.persistence.entity; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.nextskip.common.model.FrequencyBand; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; + +/** + * JPA entity for persisting WA7BNM contest series metadata. + * + *

Contest series represent the recurring contest definitions from WA7BNM + * Contest Calendar. Each series is identified by a unique {@code wa7bnmRef} + * (the {@code ref} parameter from contest detail page URLs). + * + *

This entity stores scraped metadata including permitted bands, modes, + * exchange format, and links to official rules. The {@code revisionDate} + * field enables change detection to avoid redundant scraping. + * + *

Bands and modes are stored using {@link ElementCollection} which creates + * separate junction tables (contest_series_bands and contest_series_modes). + */ +@Entity +@Table(name = "contest_series", indexes = { + @Index(name = "idx_contest_series_wa7bnm_ref", columnList = "wa7bnm_ref") +}) +public class ContestSeriesEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "wa7bnm_ref", nullable = false, unique = true, length = 20) + private String wa7bnmRef; + + @Column(name = "name", length = 200) + private String name; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "contest_series_bands", + joinColumns = @JoinColumn(name = "series_id") + ) + @Column(name = "band", length = 20) + @Enumerated(EnumType.STRING) + private Set bands = EnumSet.noneOf(FrequencyBand.class); + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "contest_series_modes", + joinColumns = @JoinColumn(name = "series_id") + ) + @Column(name = "mode", length = 20) + private Set modes = new HashSet<>(); + + @Column(name = "sponsor", length = 100) + private String sponsor; + + @Column(name = "official_rules_url", length = 500) + private String officialRulesUrl; + + @Column(name = "exchange", length = 200) + private String exchange; + + @Column(name = "cabrillo_name", length = 50) + private String cabrilloName; + + @Column(name = "revision_date") + private LocalDate revisionDate; + + @Column(name = "last_scraped_at") + private Instant lastScrapedAt; + + /** + * Default constructor required by JPA. + */ + protected ContestSeriesEntity() { + // JPA requires no-arg constructor + } + + /** + * Creates a new entity with all fields. + * + * @param wa7bnmRef unique WA7BNM reference identifier + * @param name contest series name + * @param bands permitted frequency bands + * @param modes permitted operating modes + * @param sponsor sponsoring organization + * @param officialRulesUrl URL to official contest rules + * @param exchange expected exchange format + * @param cabrilloName Cabrillo log contest identifier + * @param revisionDate page revision date for change detection + * @param lastScrapedAt when this data was last scraped + */ + public ContestSeriesEntity(String wa7bnmRef, String name, + Set bands, Set modes, + String sponsor, String officialRulesUrl, + String exchange, String cabrilloName, + LocalDate revisionDate, Instant lastScrapedAt) { + this.wa7bnmRef = wa7bnmRef; + this.name = name; + this.bands = createBandsSet(bands); + this.modes = modes != null ? new HashSet<>(modes) : new HashSet<>(); + this.sponsor = sponsor; + this.officialRulesUrl = officialRulesUrl; + this.exchange = exchange; + this.cabrilloName = cabrilloName; + this.revisionDate = revisionDate; + this.lastScrapedAt = lastScrapedAt; + } + + private static Set createBandsSet(Set bands) { + if (bands == null || bands.isEmpty()) { + return EnumSet.noneOf(FrequencyBand.class); + } + return EnumSet.copyOf(bands); + } + + // Getters + + public Long getId() { + return id; + } + + public String getWa7bnmRef() { + return wa7bnmRef; + } + + public String getName() { + return name; + } + + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "JPA requires mutable collection access") + public Set getBands() { + return bands; + } + + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "JPA requires mutable collection access") + public Set getModes() { + return modes; + } + + public String getSponsor() { + return sponsor; + } + + public String getOfficialRulesUrl() { + return officialRulesUrl; + } + + public String getExchange() { + return exchange; + } + + public String getCabrilloName() { + return cabrilloName; + } + + public LocalDate getRevisionDate() { + return revisionDate; + } + + public Instant getLastScrapedAt() { + return lastScrapedAt; + } + + // Setters (for JPA) + + public void setWa7bnmRef(String wa7bnmRef) { + this.wa7bnmRef = wa7bnmRef; + } + + public void setName(String name) { + this.name = name; + } + + public void setBands(Set bands) { + this.bands = createBandsSet(bands); + } + + public void setModes(Set modes) { + this.modes = modes != null ? new HashSet<>(modes) : new HashSet<>(); + } + + public void setSponsor(String sponsor) { + this.sponsor = sponsor; + } + + public void setOfficialRulesUrl(String officialRulesUrl) { + this.officialRulesUrl = officialRulesUrl; + } + + public void setExchange(String exchange) { + this.exchange = exchange; + } + + public void setCabrilloName(String cabrilloName) { + this.cabrilloName = cabrilloName; + } + + public void setRevisionDate(LocalDate revisionDate) { + this.revisionDate = revisionDate; + } + + public void setLastScrapedAt(Instant lastScrapedAt) { + this.lastScrapedAt = lastScrapedAt; + } +} diff --git a/src/main/java/io/nextskip/contests/persistence/repository/ContestRepository.java b/src/main/java/io/nextskip/contests/persistence/repository/ContestRepository.java index e4a9c60b..64122c0d 100644 --- a/src/main/java/io/nextskip/contests/persistence/repository/ContestRepository.java +++ b/src/main/java/io/nextskip/contests/persistence/repository/ContestRepository.java @@ -2,6 +2,7 @@ import io.nextskip.contests.persistence.entity.ContestEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.time.Instant; @@ -55,4 +56,21 @@ List findByStartTimeBeforeAndEndTimeAfterOrderByStartTimeAsc( * @return list of contests by that sponsor */ List findBySponsorOrderByStartTimeAsc(String sponsor); + + /** + * Find all contests with a given WA7BNM reference. + * + * @param wa7bnmRef the WA7BNM reference identifier + * @return list of contests with this reference + */ + List findByWa7bnmRef(String wa7bnmRef); + + /** + * Find all distinct WA7BNM references for contests ending after a given time. + * + * @param endTime the minimum end time + * @return list of distinct references + */ + @Query("SELECT DISTINCT c.wa7bnmRef FROM ContestEntity c WHERE c.endTime > :endTime AND c.wa7bnmRef IS NOT NULL") + List findDistinctWa7bnmRefsByEndTimeAfter(Instant endTime); } diff --git a/src/main/java/io/nextskip/contests/persistence/repository/ContestSeriesRepository.java b/src/main/java/io/nextskip/contests/persistence/repository/ContestSeriesRepository.java new file mode 100644 index 00000000..4d47e512 --- /dev/null +++ b/src/main/java/io/nextskip/contests/persistence/repository/ContestSeriesRepository.java @@ -0,0 +1,26 @@ +package io.nextskip.contests.persistence.repository; + +import io.nextskip.contests.persistence.entity.ContestSeriesEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Repository for contest series persistence operations. + * + *

Provides data access for WA7BNM contest series metadata. Each series + * is uniquely identified by its WA7BNM reference (the {@code ref} parameter + * from contest detail page URLs). + */ +@Repository +public interface ContestSeriesRepository extends JpaRepository { + + /** + * Find a contest series by its WA7BNM reference. + * + * @param wa7bnmRef the unique WA7BNM reference identifier + * @return the contest series if found + */ + Optional findByWa7bnmRef(String wa7bnmRef); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b3649953..f80cbeb0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -70,10 +70,13 @@ db-scheduler: heartbeat-interval: 5m # Heartbeat for detecting dead executions shutdown-max-wait: 30s # Max wait time on graceful shutdown -# Data Refresh Scheduler Configuration (legacy - to be removed) +# NextSkip Configuration nextskip: refresh: eager-load: true # Warm all caches on application startup + contests: + series: + rate-limit-seconds: 5 # Seconds to wait between scrape requests # Resilience4j Circuit Breaker Configuration resilience4j: @@ -105,6 +108,10 @@ resilience4j: contests: baseConfig: default waitDurationInOpenState: 120s # Contests update less frequently + contest-series: + baseConfig: default + waitDurationInOpenState: 300s # Contest series - longer wait during outages + minimumNumberOfCalls: 3 # Fewer calls before opening circuit retry: configs: @@ -132,6 +139,10 @@ resilience4j: contests: baseConfig: default maxAttempts: 2 # Contests - fewer retries, data changes infrequently + contest-series: + baseConfig: default + maxAttempts: 2 # Contest series - fewer retries + waitDuration: 2s # Longer wait between retries # Actuator Configuration management: diff --git a/src/main/resources/db/changelog/migrations/008-contest-series-table.yaml b/src/main/resources/db/changelog/migrations/008-contest-series-table.yaml new file mode 100644 index 00000000..acabe69b --- /dev/null +++ b/src/main/resources/db/changelog/migrations/008-contest-series-table.yaml @@ -0,0 +1,139 @@ +databaseChangeLog: + - changeSet: + id: 008-create-contest-series-table + author: nextskip + comment: Contest series table for storing scraped WA7BNM contest metadata + changes: + - createTable: + tableName: contest_series + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: wa7bnm_ref + type: varchar(20) + constraints: + nullable: false + unique: true + - column: + name: name + type: varchar(200) + - column: + name: sponsor + type: varchar(100) + - column: + name: official_rules_url + type: varchar(500) + - column: + name: exchange + type: varchar(200) + - column: + name: cabrillo_name + type: varchar(50) + - column: + name: revision_date + type: date + - column: + name: last_scraped_at + type: timestamp with time zone + - createIndex: + indexName: idx_contest_series_wa7bnm_ref + tableName: contest_series + columns: + - column: + name: wa7bnm_ref + + - changeSet: + id: 008-create-contest-series-bands-table + author: nextskip + comment: Junction table for contest series frequency bands (ElementCollection) + changes: + - createTable: + tableName: contest_series_bands + columns: + - column: + name: series_id + type: bigint + constraints: + nullable: false + - column: + name: band + type: varchar(20) + constraints: + nullable: false + - addForeignKeyConstraint: + baseTableName: contest_series_bands + baseColumnNames: series_id + referencedTableName: contest_series + referencedColumnNames: id + constraintName: fk_contest_series_bands_series + onDelete: CASCADE + - createIndex: + indexName: idx_contest_series_bands_series_id + tableName: contest_series_bands + columns: + - column: + name: series_id + - sql: + comment: CHECK constraint for valid FrequencyBand enum values + sql: > + ALTER TABLE contest_series_bands + ADD CONSTRAINT chk_contest_series_bands_band_valid + CHECK (band IN ('BAND_160M', 'BAND_80M', 'BAND_60M', 'BAND_40M', + 'BAND_30M', 'BAND_20M', 'BAND_17M', 'BAND_15M', + 'BAND_12M', 'BAND_10M', 'BAND_6M', 'BAND_2M')) + + - changeSet: + id: 008-create-contest-series-modes-table + author: nextskip + comment: Junction table for contest series operating modes (ElementCollection) + changes: + - createTable: + tableName: contest_series_modes + columns: + - column: + name: series_id + type: bigint + constraints: + nullable: false + - column: + name: mode + type: varchar(20) + constraints: + nullable: false + - addForeignKeyConstraint: + baseTableName: contest_series_modes + baseColumnNames: series_id + referencedTableName: contest_series + referencedColumnNames: id + constraintName: fk_contest_series_modes_series + onDelete: CASCADE + - createIndex: + indexName: idx_contest_series_modes_series_id + tableName: contest_series_modes + columns: + - column: + name: series_id + + - changeSet: + id: 008-add-wa7bnm-ref-to-contests + author: nextskip + comment: Add WA7BNM reference to contests table for linking to series + changes: + - addColumn: + tableName: contests + columns: + - column: + name: wa7bnm_ref + type: varchar(20) + - createIndex: + indexName: idx_contests_wa7bnm_ref + tableName: contests + columns: + - column: + name: wa7bnm_ref diff --git a/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java b/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java new file mode 100644 index 00000000..04f64add --- /dev/null +++ b/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java @@ -0,0 +1,448 @@ +package io.nextskip.contests.internal; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.retry.RetryRegistry; +import io.nextskip.common.client.ExternalApiException; +import io.nextskip.common.model.FrequencyBand; +import io.nextskip.contests.internal.dto.ContestSeriesDto; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.Set; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for ContestSeriesClient using WireMock. + */ +@SuppressWarnings("PMD.TooManyMethods") // Comprehensive test suite for scraper +class ContestSeriesClientTest { + + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + private static final String CONTENT_TYPE_HTML = "text/html"; + private static final String TEST_CONTEST_NAME = "Test Contest"; + private static final String TD_CLOSE = "\n"; + + private WireMockServer wireMockServer; + private ContestSeriesClient client; + private CircuitBreakerRegistry circuitBreakerRegistry; + private RetryRegistry retryRegistry; + + @BeforeEach + void setUp() { + wireMockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + wireMockServer.start(); + + circuitBreakerRegistry = CircuitBreakerRegistry.ofDefaults(); + retryRegistry = RetryRegistry.ofDefaults(); + + String baseUrl = "http://localhost:" + wireMockServer.port(); + WebClient.Builder webClientBuilder = WebClient.builder(); + + client = new StubContestSeriesClient(webClientBuilder, circuitBreakerRegistry, + retryRegistry, baseUrl); + } + + @AfterEach + void tearDown() { + wireMockServer.stop(); + } + + // ========== fetchSeriesDetails tests ========== + + @Test + void testFetchSeriesDetails_Success_ParsesAllFields() { + String html = createContestDetailPage( + "Indiana QSO Party", + "160, 80, 40, 20, 15, 10m", + "CW, SSB, Digital", + "HDXA", + "https://example.com/rules", + "RS(T) + county/state/DX", + "IN-QSO-PARTY", + "November 1, 2025" + ); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=8")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("8"); + + assertNotNull(result); + assertEquals("8", result.wa7bnmRef()); + assertEquals("Indiana QSO Party", result.name()); + assertEquals("HDXA", result.sponsor()); + assertEquals("https://example.com/rules", result.officialRulesUrl()); + assertEquals("RS(T) + county/state/DX", result.exchange()); + assertEquals("IN-QSO-PARTY", result.cabrilloName()); + assertEquals(LocalDate.of(2025, 11, 1), result.revisionDate()); + + wireMockServer.verify(getRequestedFor(urlEqualTo("/contestdetails.php?ref=8"))); + } + + @Test + void testFetchSeriesDetails_BandsParsing_Any() { + String html = createContestDetailPage(TEST_CONTEST_NAME, "Any", "CW", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=1")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("1"); + + // "Any" should include all HF bands + assertTrue(result.bands().contains(FrequencyBand.BAND_160M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_80M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_40M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_20M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_15M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_10M)); + } + + @Test + void testFetchSeriesDetails_BandsParsing_ExceptWarc() { + String html = createContestDetailPage(TEST_CONTEST_NAME, "Any except WARC", "CW", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=2")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("2"); + + // Should have main contest bands but not WARC + assertTrue(result.bands().contains(FrequencyBand.BAND_160M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_80M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_40M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_20M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_15M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_10M)); + + // WARC bands should be excluded + assertFalse(result.bands().contains(FrequencyBand.BAND_60M)); + assertFalse(result.bands().contains(FrequencyBand.BAND_30M)); + assertFalse(result.bands().contains(FrequencyBand.BAND_17M)); + assertFalse(result.bands().contains(FrequencyBand.BAND_12M)); + } + + @Test + void testFetchSeriesDetails_BandsParsing_SpecificBands() { + String html = createContestDetailPage(TEST_CONTEST_NAME, "20, 40, 80m", "CW", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=3")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("3"); + + assertEquals(3, result.bands().size()); + assertTrue(result.bands().contains(FrequencyBand.BAND_20M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_40M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_80M)); + } + + @Test + void testFetchSeriesDetails_ModesParsing_Any() { + String html = createContestDetailPage(TEST_CONTEST_NAME, "40m", "Any", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=4")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("4"); + + assertTrue(result.modes().contains("CW")); + assertTrue(result.modes().contains("SSB")); + assertTrue(result.modes().contains("Digital")); + } + + @Test + void testFetchSeriesDetails_ModesParsing_Phone() { + String html = createContestDetailPage(TEST_CONTEST_NAME, "40m", "Phone", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=5")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("5"); + + assertTrue(result.modes().contains("SSB")); + } + + @Test + void testFetchSeriesDetails_ModesParsing_Digital() { + String html = createContestDetailPage(TEST_CONTEST_NAME, "40m", "RTTY, FT8", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=6")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("6"); + + assertTrue(result.modes().contains("Digital")); + } + + @Test + void testFetchSeriesDetails_ServerError_ThrowsException() { + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=999")) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + assertThrows(ExternalApiException.class, () -> client.fetchSeriesDetails("999")); + } + + @Test + void testFetchSeriesDetails_NotFound_ThrowsException() { + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=404")) + .willReturn(aResponse() + .withStatus(404) + .withBody("Not Found"))); + + assertThrows(ExternalApiException.class, () -> client.fetchSeriesDetails("404")); + } + + @Test + void testFetchSeriesDetails_EmptyResponse_ThrowsException() { + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=empty")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(""))); + + assertThrows(ExternalApiException.class, () -> client.fetchSeriesDetails("empty")); + } + + @Test + void testFetchSeriesDetails_MissingFields_ReturnsNulls() { + // Minimal HTML with no contest details + String html = "" + TEST_CONTEST_NAME + "" + + "

" + TEST_CONTEST_NAME + "

"; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=7")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("7"); + + assertNotNull(result); + assertEquals("7", result.wa7bnmRef()); + assertEquals(TEST_CONTEST_NAME, result.name()); + assertTrue(result.bands().isEmpty()); + assertTrue(result.modes().isEmpty()); + assertNull(result.sponsor()); + assertNull(result.officialRulesUrl()); + assertNull(result.exchange()); + assertNull(result.cabrilloName()); + assertNull(result.revisionDate()); + } + + // ========== fetchRevisionDate tests ========== + + @Test + void testFetchRevisionDate_Success() { + String html = """ + + + Revision Date: November 1, 2025 + + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=10")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + Optional result = client.fetchRevisionDate("10"); + + assertTrue(result.isPresent()); + assertEquals(LocalDate.of(2025, 11, 1), result.get()); + } + + @Test + void testFetchRevisionDate_NotFound_ReturnsEmpty() { + String html = """ + + + No revision date here + + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=11")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + Optional result = client.fetchRevisionDate("11"); + + assertTrue(result.isEmpty()); + } + + @Test + void testFetchRevisionDate_ServerError_ReturnsEmpty() { + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=12")) + .willReturn(aResponse() + .withStatus(500) + .withBody("Error"))); + + Optional result = client.fetchRevisionDate("12"); + + assertTrue(result.isEmpty()); + } + + // ========== parseRevisionDate tests ========== + + @Test + void testParseRevisionDate_ValidFormat_Parses() { + String html = "Some text\nRevision Date: December 25, 2025\nMore text"; + + Optional result = client.parseRevisionDate(html); + + assertTrue(result.isPresent()); + assertEquals(LocalDate.of(2025, 12, 25), result.get()); + } + + @Test + void testParseRevisionDate_SingleDigitDay_Parses() { + String html = "Revision Date: January 5, 2025"; + + Optional result = client.parseRevisionDate(html); + + assertTrue(result.isPresent()); + assertEquals(LocalDate.of(2025, 1, 5), result.get()); + } + + @Test + void testParseRevisionDate_MissingDate_ReturnsEmpty() { + String html = "No date information"; + + Optional result = client.parseRevisionDate(html); + + assertTrue(result.isEmpty()); + } + + @Test + void testParseRevisionDate_InvalidFormat_ReturnsEmpty() { + String html = "Revision Date: 2025-12-25"; + + Optional result = client.parseRevisionDate(html); + + assertTrue(result.isEmpty()); + } + + // ========== parseBands tests ========== + + @Test + void testParseBands_AllFormat() { + String html = createContestDetailPage("Test", "All HF bands", "CW", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=20")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("20"); + + // "All" should include all HF bands + assertTrue(result.bands().size() >= 10); + } + + // ========== Helper methods ========== + + /** + * Creates a mock WA7BNM contest detail page HTML. + */ + private String createContestDetailPage( + String name, + String bands, + String modes, + String sponsor, + String rulesUrl, + String exchange, + String cabrilloName, + String revisionDate) { + + StringBuilder html = new StringBuilder(); + html.append("\n").append(name) + .append(" - Contest Calendar\n\n

").append(name) + .append("

\n\n"); + + if (bands != null) { + html.append("
Bands:").append(bands).append(TD_CLOSE); + } + if (modes != null) { + html.append("
Mode:").append(modes).append(TD_CLOSE); + } + if (sponsor != null) { + html.append("
Sponsor:").append(sponsor).append(TD_CLOSE); + } + if (rulesUrl != null) { + html.append("
Find rules at:Rules").append(TD_CLOSE); + } + if (exchange != null) { + html.append("
Exchange:").append(exchange).append(TD_CLOSE); + } + if (cabrilloName != null) { + html.append("
Cabrillo name:").append(cabrilloName).append(TD_CLOSE); + } + + html.append("
\n"); + + if (revisionDate != null) { + html.append("

Revision Date: ").append(revisionDate).append("

\n"); + } + + html.append("\n"); + return html.toString(); + } + + /** + * Stub subclass that allows URL override for testing. + */ + static class StubContestSeriesClient extends ContestSeriesClient { + StubContestSeriesClient( + WebClient.Builder webClientBuilder, + CircuitBreakerRegistry circuitBreakerRegistry, + RetryRegistry retryRegistry, + String testUrl) { + super(webClientBuilder, circuitBreakerRegistry, retryRegistry, testUrl); + } + } +} diff --git a/src/test/java/io/nextskip/contests/internal/scheduler/ContestRefreshServiceTest.java b/src/test/java/io/nextskip/contests/internal/scheduler/ContestRefreshServiceTest.java index e1b9bc35..eda2ca4e 100644 --- a/src/test/java/io/nextskip/contests/internal/scheduler/ContestRefreshServiceTest.java +++ b/src/test/java/io/nextskip/contests/internal/scheduler/ContestRefreshServiceTest.java @@ -134,6 +134,79 @@ void testExecuteRefresh_EmptyList_DeletesAllAndSavesNothing() { assertThat(captor.getValue()).isEmpty(); } + @Test + void testExtractWa7bnmRef_ValidUrl_ReturnsRef() { + String url = "https://contestcalendar.com/contestdetails.php?ref=8"; + + String result = service.extractWa7bnmRef(url); + + assertThat(result).isEqualTo("8"); + } + + @Test + void testExtractWa7bnmRef_UrlWithExtraParams_ReturnsRef() { + String url = "https://contestcalendar.com/contestdetails.php?ref=123&other=value"; + + String result = service.extractWa7bnmRef(url); + + assertThat(result).isEqualTo("123"); + } + + @Test + void testExtractWa7bnmRef_UrlWithRefAfterAmpersand_ReturnsRef() { + String url = "https://contestcalendar.com/contestdetails.php?other=value&ref=456"; + + String result = service.extractWa7bnmRef(url); + + assertThat(result).isEqualTo("456"); + } + + @Test + void testExtractWa7bnmRef_NullUrl_ReturnsNull() { + String result = service.extractWa7bnmRef(null); + + assertThat(result).isNull(); + } + + @Test + void testExtractWa7bnmRef_BlankUrl_ReturnsNull() { + String result = service.extractWa7bnmRef(" "); + + assertThat(result).isNull(); + } + + @Test + void testExtractWa7bnmRef_UrlWithoutRef_ReturnsNull() { + String url = "https://contestcalendar.com/contestdetails.php?other=value"; + + String result = service.extractWa7bnmRef(url); + + assertThat(result).isNull(); + } + + @Test + void testExecuteRefresh_SetsWa7bnmRef() { + List dtos = List.of( + new ContestICalDto( + "Test Contest", + Instant.now().plus(1, ChronoUnit.DAYS), + Instant.now().plus(3, ChronoUnit.DAYS), + "https://contestcalendar.com/contestdetails.php?ref=42" + ) + ); + when(contestClient.fetch()).thenReturn(dtos); + + service.executeRefresh(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(repository).saveAll(captor.capture()); + + List saved = captor.getValue(); + assertThat(saved).hasSize(1); + assertThat(saved.get(0).getWa7bnmRef()).isEqualTo("42"); + } + private List createTestDtos() { Instant now = Instant.now(); return List.of( diff --git a/src/test/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTaskTest.java b/src/test/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTaskTest.java new file mode 100644 index 00000000..2a01c733 --- /dev/null +++ b/src/test/java/io/nextskip/contests/internal/scheduler/ContestSeriesRefreshTaskTest.java @@ -0,0 +1,254 @@ +package io.nextskip.contests.internal.scheduler; + +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.kagkarlsson.scheduler.task.helper.RecurringTask; +import io.nextskip.common.config.CacheConfig; +import io.nextskip.common.model.FrequencyBand; +import io.nextskip.contests.internal.ContestSeriesClient; +import io.nextskip.contests.internal.dto.ContestSeriesDto; +import io.nextskip.contests.model.Contest; +import io.nextskip.contests.persistence.entity.ContestEntity; +import io.nextskip.contests.persistence.entity.ContestSeriesEntity; +import io.nextskip.contests.persistence.repository.ContestRepository; +import io.nextskip.contests.persistence.repository.ContestSeriesRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for ContestSeriesRefreshTask. + */ +@ExtendWith(MockitoExtension.class) +class ContestSeriesRefreshTaskTest { + + @Mock + private ContestSeriesClient seriesClient; + + @Mock + private ContestRepository contestRepository; + + @Mock + private ContestSeriesRepository seriesRepository; + + @Mock + private LoadingCache> contestsCache; + + private ContestSeriesRefreshTask task; + + @BeforeEach + void setUp() { + task = new ContestSeriesRefreshTask(); + } + + @Test + void testContestSeriesRefreshTask_ReturnsValidRecurringTask() { + RecurringTask recurringTask = task.contestSeriesRecurringTask( + seriesClient, contestRepository, seriesRepository, contestsCache, 5); + + assertThat(recurringTask).isNotNull(); + assertThat(recurringTask.getName()).isEqualTo("contest-series-refresh"); + } + + @Test + void testExecuteRefresh_NewSeries_ScrapesAndSaves() { + // Given: A contest ref that doesn't exist in series repository + when(contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(any(Instant.class))) + .thenReturn(List.of("8")); + when(seriesRepository.findByWa7bnmRef("8")).thenReturn(Optional.empty()); + when(seriesClient.fetchRevisionDate("8")) + .thenReturn(Optional.of(LocalDate.of(2025, 11, 1))); + when(seriesClient.fetchSeriesDetails("8")).thenReturn(createTestSeriesDto()); + when(contestRepository.findByWa7bnmRef("8")).thenReturn(List.of(createTestContestEntity())); + + // When + task.executeRefresh(seriesClient, contestRepository, seriesRepository, contestsCache, 0); + + // Then: Should scrape and save series + verify(seriesClient).fetchSeriesDetails("8"); + verify(seriesRepository).save(any(ContestSeriesEntity.class)); + verify(contestRepository).saveAll(any()); + verify(contestsCache).refresh(CacheConfig.CACHE_KEY); + } + + @Test + void testExecuteRefresh_ExistingSeriesUnchanged_Skips() { + // Given: A series that exists with same revision date + LocalDate revisionDate = LocalDate.of(2025, 11, 1); + ContestSeriesEntity existingSeries = createTestSeriesEntity(revisionDate); + + when(contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(any(Instant.class))) + .thenReturn(List.of("8")); + when(seriesRepository.findByWa7bnmRef("8")).thenReturn(Optional.of(existingSeries)); + when(seriesClient.fetchRevisionDate("8")).thenReturn(Optional.of(revisionDate)); + + // When + task.executeRefresh(seriesClient, contestRepository, seriesRepository, contestsCache, 0); + + // Then: Should skip full scrape + verify(seriesClient, never()).fetchSeriesDetails(any()); + verify(seriesRepository, never()).save(any()); + } + + @Test + void testExecuteRefresh_ExistingSeriesChanged_Rescrapes() { + // Given: A series that exists but has different revision date + LocalDate oldDate = LocalDate.of(2025, 10, 1); + LocalDate newDate = LocalDate.of(2025, 11, 1); + ContestSeriesEntity existingSeries = createTestSeriesEntity(oldDate); + + when(contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(any(Instant.class))) + .thenReturn(List.of("8")); + when(seriesRepository.findByWa7bnmRef("8")).thenReturn(Optional.of(existingSeries)); + when(seriesClient.fetchRevisionDate("8")).thenReturn(Optional.of(newDate)); + when(seriesClient.fetchSeriesDetails("8")).thenReturn(createTestSeriesDto()); + when(contestRepository.findByWa7bnmRef("8")).thenReturn(List.of(createTestContestEntity())); + + // When + task.executeRefresh(seriesClient, contestRepository, seriesRepository, contestsCache, 0); + + // Then: Should rescrape + verify(seriesClient).fetchSeriesDetails("8"); + verify(seriesRepository).save(any(ContestSeriesEntity.class)); + } + + @Test + void testExecuteRefresh_NoContestsInWindow_NoScraping() { + when(contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(any(Instant.class))) + .thenReturn(List.of()); + + task.executeRefresh(seriesClient, contestRepository, seriesRepository, contestsCache, 0); + + verify(seriesClient, never()).fetchRevisionDate(any()); + verify(seriesClient, never()).fetchSeriesDetails(any()); + verify(contestsCache).refresh(CacheConfig.CACHE_KEY); + } + + @Test + void testExecuteRefresh_CopiesSeriesDataToContests() { + // Given + when(contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(any(Instant.class))) + .thenReturn(List.of("8")); + when(seriesRepository.findByWa7bnmRef("8")).thenReturn(Optional.empty()); + when(seriesClient.fetchRevisionDate("8")) + .thenReturn(Optional.of(LocalDate.of(2025, 11, 1))); + when(seriesClient.fetchSeriesDetails("8")).thenReturn(createTestSeriesDto()); + + ContestEntity contestEntity = createTestContestEntity(); + when(contestRepository.findByWa7bnmRef("8")).thenReturn(List.of(contestEntity)); + + // When + task.executeRefresh(seriesClient, contestRepository, seriesRepository, contestsCache, 0); + + // Then: Contest entity should be updated with series data + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(contestRepository).saveAll(captor.capture()); + + List saved = captor.getValue(); + assertThat(saved).hasSize(1); + assertThat(saved.get(0).getSponsor()).isEqualTo("HDXA"); + assertThat(saved.get(0).getBands()).contains(FrequencyBand.BAND_40M); + assertThat(saved.get(0).getModes()).contains("CW"); + } + + @Test + void testExecuteRefresh_SeriesClientError_ContinuesWithOtherRefs() { + // Given: Two refs, first one fails + when(contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(any(Instant.class))) + .thenReturn(List.of("8", "9")); + + // First ref fails + when(seriesRepository.findByWa7bnmRef("8")).thenReturn(Optional.empty()); + when(seriesClient.fetchRevisionDate("8")) + .thenThrow(new RuntimeException("Network error")); + + // Second ref succeeds + when(seriesRepository.findByWa7bnmRef("9")).thenReturn(Optional.empty()); + when(seriesClient.fetchRevisionDate("9")) + .thenReturn(Optional.of(LocalDate.of(2025, 11, 1))); + when(seriesClient.fetchSeriesDetails("9")).thenReturn(createTestSeriesDto()); + when(contestRepository.findByWa7bnmRef("9")).thenReturn(List.of()); + + // When + task.executeRefresh(seriesClient, contestRepository, seriesRepository, contestsCache, 0); + + // Then: Should continue processing after error + verify(seriesClient).fetchSeriesDetails("9"); + } + + @Test + void testExecuteRefresh_SeriesClientError_AllFail_StillRefreshesCache() { + // Given: All refs fail + when(contestRepository.findDistinctWa7bnmRefsByEndTimeAfter(any(Instant.class))) + .thenReturn(List.of("8")); + when(seriesRepository.findByWa7bnmRef("8")).thenReturn(Optional.empty()); + when(seriesClient.fetchRevisionDate("8")) + .thenThrow(new RuntimeException("Network error")); + + // When + task.executeRefresh(seriesClient, contestRepository, seriesRepository, contestsCache, 0); + + // Then: Cache should still be refreshed + verify(contestsCache).refresh(CacheConfig.CACHE_KEY); + } + + private ContestSeriesDto createTestSeriesDto() { + return new ContestSeriesDto( + "8", + "Indiana QSO Party", + Set.of(FrequencyBand.BAND_40M, FrequencyBand.BAND_20M), + Set.of("CW", "SSB"), + "HDXA", + "https://example.com/rules", + "RS(T) + county", + "IN-QSO-PARTY", + LocalDate.of(2025, 11, 1) + ); + } + + private ContestSeriesEntity createTestSeriesEntity(LocalDate revisionDate) { + return new ContestSeriesEntity( + "8", + "Indiana QSO Party", + Set.of(FrequencyBand.BAND_40M), + Set.of("CW"), + "HDXA", + "https://example.com/rules", + "RS(T) + county", + "IN-QSO-PARTY", + revisionDate, + Instant.now().minus(1, ChronoUnit.DAYS) + ); + } + + private ContestEntity createTestContestEntity() { + return new ContestEntity( + "Indiana QSO Party", + Instant.now().plus(1, ChronoUnit.DAYS), + Instant.now().plus(3, ChronoUnit.DAYS), + Set.of(), + Set.of(), + null, + "https://contestcalendar.com/contestdetails.php?ref=8", + null + ); + } +} diff --git a/src/test/java/io/nextskip/contests/persistence/ContestSeriesEntityIntegrationTest.java b/src/test/java/io/nextskip/contests/persistence/ContestSeriesEntityIntegrationTest.java new file mode 100644 index 00000000..45822199 --- /dev/null +++ b/src/test/java/io/nextskip/contests/persistence/ContestSeriesEntityIntegrationTest.java @@ -0,0 +1,433 @@ +package io.nextskip.contests.persistence; + +import io.nextskip.common.model.FrequencyBand; +import io.nextskip.contests.persistence.entity.ContestSeriesEntity; +import io.nextskip.contests.persistence.repository.ContestSeriesRepository; +import io.nextskip.test.AbstractIntegrationTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for ContestSeriesEntity and repository operations. + * + *

Verifies: + *

    + *
  • Entity persistence with junction tables for bands and modes
  • + *
  • Repository query methods (findByWa7bnmRef)
  • + *
  • Unique constraint on wa7bnmRef
  • + *
  • Defensive copying of collections
  • + *
+ */ +@SpringBootTest +@Transactional +class ContestSeriesEntityIntegrationTest extends AbstractIntegrationTest { + + private static final String INDIANA_QSO_PARTY_REF = "8"; + private static final String INDIANA_QSO_PARTY_NAME = "Indiana QSO Party"; + private static final String CQ_WW_REF = "10"; + private static final String CQ_WW_NAME = "CQ WW DX Contest"; + private static final String TEST_SERIES_REF = "99"; + private static final String TEST_SERIES_NAME = "Test Series"; + private static final String MODE_CW = "CW"; + private static final String MODE_SSB = "SSB"; + private static final String MODE_DIGITAL = "Digital"; + private static final String SPONSOR_HDXA = "HDXA"; + private static final String SPONSOR_CQ = "CQ Magazine"; + private static final String RULES_URL = "https://example.com/rules"; + private static final String EXCHANGE = "RS(T) + county"; + private static final String CABRILLO_NAME = "IN-QSO-PARTY"; + + @Autowired + private ContestSeriesRepository repository; + + @Autowired + private EntityManager entityManager; + + @Test + void testSaveAndRetrieve_Success() { + // Given: A contest series entity + var entity = createIndianaQsoPartySeries(); + + // When: Save to database + var saved = repository.save(entity); + + // Then: Should have generated ID + assertNotNull(saved.getId()); + + // And: Should be retrievable + var found = repository.findById(saved.getId()); + assertTrue(found.isPresent()); + assertEquals(INDIANA_QSO_PARTY_NAME, found.get().getName()); + assertEquals(INDIANA_QSO_PARTY_REF, found.get().getWa7bnmRef()); + } + + @Test + void testFindByWa7bnmRef_Found_ReturnsEntity() { + // Given: A saved series + var entity = createIndianaQsoPartySeries(); + repository.save(entity); + + // When: Find by wa7bnmRef + var found = repository.findByWa7bnmRef(INDIANA_QSO_PARTY_REF); + + // Then: Should be found + assertTrue(found.isPresent()); + assertEquals(INDIANA_QSO_PARTY_NAME, found.get().getName()); + } + + @Test + void testFindByWa7bnmRef_NotFound_ReturnsEmpty() { + // When: Find by non-existent ref + var found = repository.findByWa7bnmRef("non-existent"); + + // Then: Should be empty + assertTrue(found.isEmpty()); + } + + @Test + void testSaveWithBandsAndModes_PersistsToJunctionTables() { + // Given: A series with multiple bands and modes + var entity = new ContestSeriesEntity( + CQ_WW_REF, + CQ_WW_NAME, + Set.of(FrequencyBand.BAND_160M, FrequencyBand.BAND_80M, FrequencyBand.BAND_40M, + FrequencyBand.BAND_20M, FrequencyBand.BAND_15M, FrequencyBand.BAND_10M), + Set.of(MODE_CW, MODE_SSB), + SPONSOR_CQ, + RULES_URL, + "RST + CQ Zone", + "CQ-WW-CW", + LocalDate.of(2025, 10, 1), + Instant.now() + ); + + // When: Save and flush to database + var saved = repository.saveAndFlush(entity); + + // Clear persistence context to force reload from DB + entityManager.clear(); + + // Then: Should reload with all bands and modes from junction tables + var reloaded = repository.findById(saved.getId()).orElseThrow(); + assertEquals(6, reloaded.getBands().size()); + assertEquals(2, reloaded.getModes().size()); + assertTrue(reloaded.getBands().contains(FrequencyBand.BAND_20M)); + assertTrue(reloaded.getModes().contains(MODE_CW)); + } + + @Test + void testSaveWithEmptyCollections_Success() { + // Given: A series with empty bands and modes + var entity = new ContestSeriesEntity( + TEST_SERIES_REF, + TEST_SERIES_NAME, + Set.of(), Set.of(), + null, null, null, null, + null, null + ); + + // When: Save to database + var saved = repository.saveAndFlush(entity); + + // Then: Should succeed with empty collections + entityManager.clear(); + var reloaded = repository.findById(saved.getId()).orElseThrow(); + assertTrue(reloaded.getBands().isEmpty()); + assertTrue(reloaded.getModes().isEmpty()); + } + + @Test + void testSaveWithNullWa7bnmRef_ThrowsException() { + // Given: An entity with null wa7bnmRef (required field) + var entity = new ContestSeriesEntity( + null, + TEST_SERIES_NAME, + Set.of(), Set.of(), + null, null, null, null, + null, null + ); + + // When/Then: Should throw exception on save + assertThrows(DataIntegrityViolationException.class, + () -> repository.saveAndFlush(entity)); + } + + @Test + void testSaveDuplicateWa7bnmRef_ThrowsException() { + // Given: An existing series + repository.saveAndFlush(createIndianaQsoPartySeries()); + entityManager.clear(); + + // When: Try to save another with same wa7bnmRef + var duplicate = new ContestSeriesEntity( + INDIANA_QSO_PARTY_REF, // Same ref + "Different Name", + Set.of(), Set.of(), + null, null, null, null, + null, null + ); + + // Then: Should throw unique constraint violation + assertThrows(DataIntegrityViolationException.class, + () -> repository.saveAndFlush(duplicate)); + } + + @Test + void testAllFrequencyBands_CanBePersisted() { + // Given: A series with all frequency bands + var allBands = Set.of(FrequencyBand.values()); + var entity = new ContestSeriesEntity( + TEST_SERIES_REF, + TEST_SERIES_NAME, + allBands, Set.of(MODE_CW), + null, null, null, null, + null, null + ); + + // When: Save and reload + var saved = repository.saveAndFlush(entity); + entityManager.clear(); + var reloaded = repository.findById(saved.getId()).orElseThrow(); + + // Then: All bands should be persisted + assertEquals(FrequencyBand.values().length, reloaded.getBands().size()); + for (FrequencyBand band : FrequencyBand.values()) { + assertTrue(reloaded.getBands().contains(band), "Should contain band: " + band); + } + } + + @Test + void testDeleteSeries_CascadesToJunctionTables() { + // Given: A series with bands and modes + var entity = createIndianaQsoPartySeries(); + var saved = repository.saveAndFlush(entity); + var seriesId = saved.getId(); + assertFalse(saved.getBands().isEmpty()); + + // When: Delete the series + repository.deleteById(seriesId); + repository.flush(); + + // Then: Series should be deleted (junction tables cascade) + assertTrue(repository.findById(seriesId).isEmpty()); + } + + @Test + void testRevisionDate_PersistsCorrectly() { + // Given: A series with a revision date + var revisionDate = LocalDate.of(2025, 11, 1); + var entity = new ContestSeriesEntity( + TEST_SERIES_REF, + TEST_SERIES_NAME, + Set.of(), Set.of(), + null, null, null, null, + revisionDate, null + ); + + // When: Save and reload + var saved = repository.saveAndFlush(entity); + entityManager.clear(); + var reloaded = repository.findById(saved.getId()).orElseThrow(); + + // Then: Revision date should be preserved + assertEquals(revisionDate, reloaded.getRevisionDate()); + } + + @Test + void testLastScrapedAt_PersistsCorrectly() { + // Given: A series with lastScrapedAt timestamp + var lastScrapedAt = Instant.now().minus(1, ChronoUnit.DAYS); + var entity = new ContestSeriesEntity( + TEST_SERIES_REF, + TEST_SERIES_NAME, + Set.of(), Set.of(), + null, null, null, null, + null, lastScrapedAt + ); + + // When: Save and reload + var saved = repository.saveAndFlush(entity); + entityManager.clear(); + var reloaded = repository.findById(saved.getId()).orElseThrow(); + + // Then: LastScrapedAt should be preserved (truncated to microseconds by DB) + assertNotNull(reloaded.getLastScrapedAt()); + // Compare with tolerance for database timestamp precision + assertTrue(Math.abs(lastScrapedAt.toEpochMilli() - reloaded.getLastScrapedAt().toEpochMilli()) < 1000); + } + + @Test + void testNullOptionalFields_PersistsCorrectly() { + // Given: A series with only required fields + var entity = new ContestSeriesEntity( + TEST_SERIES_REF, + null, // name is optional + Set.of(), Set.of(), + null, null, null, null, + null, null + ); + + // When: Save and reload + var saved = repository.saveAndFlush(entity); + entityManager.clear(); + var reloaded = repository.findById(saved.getId()).orElseThrow(); + + // Then: All optional fields should be null + assertNull(reloaded.getName()); + assertNull(reloaded.getSponsor()); + assertNull(reloaded.getOfficialRulesUrl()); + assertNull(reloaded.getExchange()); + assertNull(reloaded.getCabrilloName()); + assertNull(reloaded.getRevisionDate()); + assertNull(reloaded.getLastScrapedAt()); + } + + // === Setter Coverage Tests === + + @Test + void testSetters_AllFields_UpdatesEntity() { + // Given: An entity created via constructor + var entity = createIndianaQsoPartySeries(); + var saved = repository.save(entity); + + // When: Update all fields via setters + var newRevisionDate = LocalDate.of(2025, 12, 15); + var newLastScrapedAt = Instant.now(); + saved.setWa7bnmRef("new-ref"); + saved.setName("New Series Name"); + saved.setBands(Set.of(FrequencyBand.BAND_160M, FrequencyBand.BAND_80M)); + saved.setModes(Set.of(MODE_DIGITAL)); + saved.setSponsor("New Sponsor"); + saved.setOfficialRulesUrl("https://new.example.com/rules"); + saved.setExchange("New Exchange"); + saved.setCabrilloName("NEW-CONTEST"); + saved.setRevisionDate(newRevisionDate); + saved.setLastScrapedAt(newLastScrapedAt); + + // Then: All getters should return updated values + assertEquals("new-ref", saved.getWa7bnmRef()); + assertEquals("New Series Name", saved.getName()); + assertEquals(Set.of(FrequencyBand.BAND_160M, FrequencyBand.BAND_80M), saved.getBands()); + assertEquals(Set.of(MODE_DIGITAL), saved.getModes()); + assertEquals("New Sponsor", saved.getSponsor()); + assertEquals("https://new.example.com/rules", saved.getOfficialRulesUrl()); + assertEquals("New Exchange", saved.getExchange()); + assertEquals("NEW-CONTEST", saved.getCabrilloName()); + assertEquals(newRevisionDate, saved.getRevisionDate()); + assertEquals(newLastScrapedAt, saved.getLastScrapedAt()); + } + + @Test + void testSetBands_NullValue_CreatesEmptySet() { + // Given: An entity with existing bands + var entity = createIndianaQsoPartySeries(); + assertFalse(entity.getBands().isEmpty()); + + // When: Set bands to null + entity.setBands(null); + + // Then: Should have empty set + assertTrue(entity.getBands().isEmpty()); + } + + @Test + void testSetModes_NullValue_CreatesEmptySet() { + // Given: An entity with existing modes + var entity = createIndianaQsoPartySeries(); + assertFalse(entity.getModes().isEmpty()); + + // When: Set modes to null + entity.setModes(null); + + // Then: Should have empty set + assertTrue(entity.getModes().isEmpty()); + } + + @Test + void testConstructor_NullBands_CreatesEmptySet() { + // Given/When: Create entity with null bands + var entity = new ContestSeriesEntity( + TEST_SERIES_REF, + TEST_SERIES_NAME, + null, Set.of(MODE_CW), + null, null, null, null, + null, null + ); + + // Then: Bands should be empty, not null + assertNotNull(entity.getBands()); + assertTrue(entity.getBands().isEmpty()); + } + + @Test + void testConstructor_NullModes_CreatesEmptySet() { + // Given/When: Create entity with null modes + var entity = new ContestSeriesEntity( + TEST_SERIES_REF, + TEST_SERIES_NAME, + Set.of(FrequencyBand.BAND_20M), null, + null, null, null, null, + null, null + ); + + // Then: Modes should be empty, not null + assertNotNull(entity.getModes()); + assertTrue(entity.getModes().isEmpty()); + } + + @Test + void testUpdate_ExistingSeries_PersistsChanges() { + // Given: An existing series in the database + var entity = createIndianaQsoPartySeries(); + var saved = repository.saveAndFlush(entity); + var seriesId = saved.getId(); + entityManager.clear(); + + // When: Reload and update + var reloaded = repository.findById(seriesId).orElseThrow(); + reloaded.setSponsor("Updated Sponsor"); + reloaded.setBands(Set.of(FrequencyBand.BAND_10M)); + reloaded.setRevisionDate(LocalDate.of(2025, 12, 25)); + repository.saveAndFlush(reloaded); + entityManager.clear(); + + // Then: Changes should be persisted + var updated = repository.findById(seriesId).orElseThrow(); + assertEquals("Updated Sponsor", updated.getSponsor()); + assertEquals(Set.of(FrequencyBand.BAND_10M), updated.getBands()); + assertEquals(LocalDate.of(2025, 12, 25), updated.getRevisionDate()); + } + + // Helper methods + + private ContestSeriesEntity createIndianaQsoPartySeries() { + return new ContestSeriesEntity( + INDIANA_QSO_PARTY_REF, + INDIANA_QSO_PARTY_NAME, + Set.of(FrequencyBand.BAND_40M, FrequencyBand.BAND_20M), + Set.of(MODE_CW, MODE_SSB), + SPONSOR_HDXA, + RULES_URL, + EXCHANGE, + CABRILLO_NAME, + LocalDate.of(2025, 11, 1), + Instant.now().minus(1, ChronoUnit.DAYS) + ); + } +} From 2937109e2ab2d5a19330af19c152129b1bc2d414 Mon Sep 17 00:00:00 2001 From: arunderwood Date: Fri, 2 Jan 2026 10:37:01 -0800 Subject: [PATCH 2/2] test(contests): improve branch coverage for ContestSeriesClient Add comprehensive tests for fallback parsing paths: - Definition list format (dt/dd elements) - Bold text format (b/strong elements) - parseContestName fallback when no h1 element - parseRulesUrl fallback for non-table links - Edge cases for empty fields and null parents Extract duplicate string literals to constants. --- .../internal/ContestSeriesClientTest.java | 498 +++++++++++++++++- 1 file changed, 494 insertions(+), 4 deletions(-) diff --git a/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java b/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java index 04f64add..929c9b29 100644 --- a/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java +++ b/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java @@ -37,6 +37,8 @@ class ContestSeriesClientTest { private static final String CONTENT_TYPE_HTML = "text/html"; private static final String TEST_CONTEST_NAME = "Test Contest"; private static final String TD_CLOSE = "\n"; + private static final String TEST_NAME_SHORT = "Test"; + private static final String BAND_40M = "40m"; private WireMockServer wireMockServer; private ContestSeriesClient client; @@ -166,7 +168,7 @@ void testFetchSeriesDetails_BandsParsing_SpecificBands() { @Test void testFetchSeriesDetails_ModesParsing_Any() { - String html = createContestDetailPage(TEST_CONTEST_NAME, "40m", "Any", null, null, null, null, null); + String html = createContestDetailPage(TEST_CONTEST_NAME, BAND_40M, "Any", null, null, null, null, null); wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=4")) .willReturn(aResponse() @@ -183,7 +185,7 @@ void testFetchSeriesDetails_ModesParsing_Any() { @Test void testFetchSeriesDetails_ModesParsing_Phone() { - String html = createContestDetailPage(TEST_CONTEST_NAME, "40m", "Phone", null, null, null, null, null); + String html = createContestDetailPage(TEST_CONTEST_NAME, BAND_40M, "Phone", null, null, null, null, null); wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=5")) .willReturn(aResponse() @@ -198,7 +200,7 @@ void testFetchSeriesDetails_ModesParsing_Phone() { @Test void testFetchSeriesDetails_ModesParsing_Digital() { - String html = createContestDetailPage(TEST_CONTEST_NAME, "40m", "RTTY, FT8", null, null, null, null, null); + String html = createContestDetailPage(TEST_CONTEST_NAME, BAND_40M, "RTTY, FT8", null, null, null, null, null); wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=6")) .willReturn(aResponse() @@ -369,7 +371,7 @@ void testParseRevisionDate_InvalidFormat_ReturnsEmpty() { @Test void testParseBands_AllFormat() { - String html = createContestDetailPage("Test", "All HF bands", "CW", null, null, null, null, null); + String html = createContestDetailPage(TEST_NAME_SHORT, "All HF bands", "CW", null, null, null, null, null); wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=20")) .willReturn(aResponse() @@ -383,6 +385,494 @@ void testParseBands_AllFormat() { assertTrue(result.bands().size() >= 10); } + @Test + void testParseBands_EmptyBandsField_ReturnsEmptySet() { + String html = createContestDetailPage(TEST_NAME_SHORT, " ", "CW", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=21")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("21"); + + assertTrue(result.bands().isEmpty()); + } + + @Test + void testParseModes_EmptyModeField_ReturnsEmptySet() { + String html = createContestDetailPage(TEST_NAME_SHORT, BAND_40M, " ", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=22")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("22"); + + assertTrue(result.modes().isEmpty()); + } + + @Test + void testParseModes_FmMode_ParsesFm() { + String html = createContestDetailPage(TEST_NAME_SHORT, "2m", "FM", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=23")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("23"); + + assertTrue(result.modes().contains("FM")); + } + + @Test + void testParseModes_AmMode_ParsesAm() { + String html = createContestDetailPage(TEST_NAME_SHORT, BAND_40M, "AM", null, null, null, null, null); + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=24")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("24"); + + assertTrue(result.modes().contains("AM")); + } + + // ========== parseContestName fallback tests ========== + + @Test + void testParseContestName_NoH1WithTitleAndDash_ParsesTitleBeforeDash() { + // No h1 element, only title with " - " in it + String html = "Test Contest - Contest Calendar" + + "

Some content

"; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=30")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("30"); + + assertEquals("Test Contest", result.name()); + } + + @Test + void testParseContestName_NoH1WithTitleNoDash_ParsesFullTitle() { + // No h1 element, title without " - " + String html = "Simple Contest Name" + + "

Some content

"; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=31")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("31"); + + assertEquals("Simple Contest Name", result.name()); + } + + @Test + void testParseContestName_NoH1NoTitle_ReturnsNull() { + // No h1 element, no title + String html = "

Some content

"; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=32")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("32"); + + assertNull(result.name()); + } + + @Test + void testParseContestName_EmptyH1_FallsBackToTitle() { + // Empty h1 element, should fall back to title + String html = "Fallback Title" + + "

Content

"; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=33")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("33"); + + assertEquals("Fallback Title", result.name()); + } + + // ========== Definition list parsing tests ========== + + @Test + void testParseFields_DefinitionListFormat_ParsesValues() { + // HTML using
format instead of table + String html = """ + + DL Contest + +

Definition List Contest

+
+
Bands:
40, 20m
+
Mode:
CW, SSB
+
Sponsor:
DL Club
+
Exchange:
RST + Serial
+
Cabrillo name:
DL-CONTEST
+
+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=40")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("40"); + + assertEquals("Definition List Contest", result.name()); + assertTrue(result.bands().contains(FrequencyBand.BAND_40M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_20M)); + assertTrue(result.modes().contains("CW")); + assertTrue(result.modes().contains("SSB")); + assertEquals("DL Club", result.sponsor()); + assertEquals("RST + Serial", result.exchange()); + assertEquals("DL-CONTEST", result.cabrilloName()); + } + + @Test + void testParseFields_DefinitionListWithEmptyDd_SkipsField() { + // DT with empty DD should return null + String html = """ + + Empty DD Test + +

Empty DD Contest

+
+
Sponsor:
+
Exchange:
Valid Exchange
+
+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=41")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("41"); + + assertNull(result.sponsor()); + assertEquals("Valid Exchange", result.exchange()); + } + + @Test + void testParseFields_DefinitionListWithNonDdSibling_SkipsField() { + // DT followed by non-DD element should skip + String html = """ + + Non DD Test + +

Non DD Contest

+
+
Sponsor:
Not a DD +
Exchange:
Valid Value
+
+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=42")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("42"); + + assertNull(result.sponsor()); + assertEquals("Valid Value", result.exchange()); + } + + // ========== Bold text parsing tests ========== + + @Test + void testParseFields_BoldTextFormat_ParsesValues() { + // HTML using Label: Value format + String html = """ + + Bold Contest + +

Bold Text Contest

+

Bands: 80, 40, 20m

+

Mode: CW only

+

Sponsor: Bold Club

+

Exchange: RST + Name

+

Cabrillo name: BOLD-TEST

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=50")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("50"); + + assertEquals("Bold Text Contest", result.name()); + assertTrue(result.bands().contains(FrequencyBand.BAND_80M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_40M)); + assertTrue(result.bands().contains(FrequencyBand.BAND_20M)); + assertTrue(result.modes().contains("CW")); + assertEquals("Bold Club", result.sponsor()); + assertEquals("RST + Name", result.exchange()); + assertEquals("BOLD-TEST", result.cabrilloName()); + } + + @Test + void testParseFields_StrongTextFormat_ParsesValues() { + // HTML using Label: Value format + String html = """ + + Strong Contest + +

Strong Text Contest

+

Sponsor: Strong Club

+

Exchange: RST + Grid

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=51")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("51"); + + assertEquals("Strong Club", result.sponsor()); + assertEquals("RST + Grid", result.exchange()); + } + + @Test + void testParseFields_BoldTextNoColon_ReturnsNull() { + // Bold text without colon should not match + String html = """ + + No Colon Test + +

No Colon Contest

+

Sponsor No colon here

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=52")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("52"); + + assertNull(result.sponsor()); + } + + @Test + void testParseFields_BoldTextColonAtEnd_ReturnsNull() { + // Bold text with colon at end, no value + String html = """ + + Colon End Test + +

Colon End Contest

+

Sponsor:

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=53")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("53"); + + assertNull(result.sponsor()); + } + + @Test + void testParseFields_BoldWithNoParent_ReturnsNull() { + // Bold element is root (no parent) - edge case + // This tests the null parent check in extractValueAfterColon + String html = """ + + Orphan Bold + +

Orphan Bold Contest

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=54")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("54"); + + // Should handle gracefully, returning null for fields not found + assertNull(result.sponsor()); + } + + // ========== Rules URL fallback tests ========== + + @Test + void testParseRulesUrl_NonTableLinkWithFindRulesAt_ParsesHref() { + // Rules link not in table, but in paragraph with "find rules at" + String html = """ + + Rules Test + +

Rules Link Contest

+

Find rules at: Contest Rules

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=60")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("60"); + + assertEquals("https://example.com/rules.pdf", result.officialRulesUrl()); + } + + @Test + void testParseRulesUrl_NonTableLinkWithOfficialRules_ParsesHref() { + // Rules link with "official rules" text + String html = """ + + Official Rules Test + +

Official Rules Contest

+

See the official rules at here

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=61")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("61"); + + assertEquals("https://example.com/official.pdf", result.officialRulesUrl()); + } + + @Test + void testParseRulesUrl_LinkWithoutRulesContext_ReturnsNull() { + // Link without "find rules at" or "official rules" context + String html = """ + + No Rules Context + +

No Rules Context Contest

+

Visit our website

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=62")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("62"); + + assertNull(result.officialRulesUrl()); + } + + @Test + void testParseRulesUrl_LinkWithEmptyHref_ReturnsNull() { + // Link with empty href attribute + String html = """ + + Empty Href Test + +

Empty Href Contest

+

Find rules at: Empty Link

+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=63")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("63"); + + assertNull(result.officialRulesUrl()); + } + + // ========== Table cell parsing edge cases ========== + + @Test + void testParseFields_TableWithEmptyValueCell_ReturnsNull() { + // Table row with empty value cell + String html = """ + + Empty Cell Test + +

Empty Cell Contest

+ + + +
Sponsor:
Exchange:Valid
+ + + """; + + wireMockServer.stubFor(get(urlEqualTo("/contestdetails.php?ref=70")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_HTML) + .withBody(html))); + + ContestSeriesDto result = client.fetchSeriesDetails("70"); + + assertNull(result.sponsor()); + assertEquals("Valid", result.exchange()); + } + // ========== Helper methods ========== /**