diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java new file mode 100644 index 00000000..fc05ac6b --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java @@ -0,0 +1,58 @@ +package com.cameleer.server.app.license; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Daily revalidation cron + on-startup revalidation 60s after {@link ApplicationReadyEvent}. + * + *

The startup tick catches ABSENT->ACTIVE transitions when the license was written to + * PostgreSQL between server starts (e.g. SaaS provisioning), and gives slow downstream + * components time to come up before the first license event fires. The daily cron ensures + * expirations and clock drift are caught even in long-running deployments.

+ * + *

Both invocations call {@link LicenseService#revalidate()} which is internally idempotent + * and exception-safe; this class additionally swallows any escape so a misbehaving validator + * cannot crash the scheduler thread.

+ */ +@Component +public class LicenseRevalidationJob { + + private static final Logger log = LoggerFactory.getLogger(LicenseRevalidationJob.class); + + private final LicenseService svc; + + public LicenseRevalidationJob(LicenseService svc) { + this.svc = svc; + } + + @EventListener(ApplicationReadyEvent.class) + @Async + public void onStartup() { + try { + Thread.sleep(60_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + revalidate(); + } + + @Scheduled(cron = "0 0 3 * * *") + public void daily() { + revalidate(); + } + + private void revalidate() { + try { + svc.revalidate(); + } catch (Exception e) { + log.error("Revalidation crashed: {}", e.getMessage()); + } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java new file mode 100644 index 00000000..95f65c06 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java @@ -0,0 +1,26 @@ +package com.cameleer.server.app.license; + +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class LicenseRevalidationJobTest { + + @Test + void daily_callsService() { + LicenseService svc = mock(LicenseService.class); + new LicenseRevalidationJob(svc).daily(); + verify(svc).revalidate(); + } + + @Test + void daily_swallowsServiceException() { + LicenseService svc = mock(LicenseService.class); + doThrow(new RuntimeException("revalidate failed")).when(svc).revalidate(); + // No exception escapes + new LicenseRevalidationJob(svc).daily(); + verify(svc).revalidate(); + } +}