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..929c9b29 --- /dev/null +++ b/src/test/java/io/nextskip/contests/internal/ContestSeriesClientTest.java @@ -0,0 +1,938 @@ +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 static final String TEST_NAME_SHORT = "Test"; + private static final String BAND_40M = "40m"; + + 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, BAND_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, BAND_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, BAND_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_NAME_SHORT, "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); + } + + @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 ========== + + /** + * 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) + ); + } +}