mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync interprets as 'any directory named test at any depth' — so it caught the root-level /test/ scratch directory (intended) AND every src/test/ under each module (not intended). Pattern is already anchored to /test (root-only). This commit rsyncs the accumulated src/test/ tree forward so opensource has the unit tests that have been written / updated against existing src/main/ code since the pattern regression. Going forward each per-commit sync will carry src/test/ files along with the main change.
177 lines
7.5 KiB
Java
177 lines
7.5 KiB
Java
package vip.mate.cron.delivery;
|
|
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
import org.junit.jupiter.api.BeforeAll;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
|
import vip.mate.cron.model.CronJobEntity;
|
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
|
import vip.mate.dashboard.repository.CronJobRunMapper;
|
|
|
|
import java.util.HashSet;
|
|
import java.util.Set;
|
|
import java.util.concurrent.CountDownLatch;
|
|
import java.util.concurrent.ExecutionException;
|
|
import java.util.concurrent.Executors;
|
|
import java.util.concurrent.atomic.AtomicReference;
|
|
import java.util.stream.IntStream;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
/**
|
|
* RFC-063r §2.6.1: Template-Method invariants — SQL CAS claim, marker
|
|
* methods after success / failure, exception propagation.
|
|
*/
|
|
class AbstractCronResultDeliveryTest {
|
|
|
|
private CronJobRunMapper runMapper;
|
|
private CronJobEntity job;
|
|
private CronJobRunEntity run;
|
|
|
|
/**
|
|
* Pre-warm MyBatis Plus's lambda → column cache. Without this the
|
|
* production code's {@code new LambdaUpdateWrapper<CronJobRunEntity>()}
|
|
* throws "can not find lambda cache" — the cache is normally populated
|
|
* during Spring context init, which we skip in unit tests.
|
|
*/
|
|
@BeforeAll
|
|
static void initMpLambdaCache() {
|
|
MybatisConfiguration cfg = new MybatisConfiguration();
|
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(cfg, ""), CronJobRunEntity.class);
|
|
}
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
runMapper = mock(CronJobRunMapper.class);
|
|
job = new CronJobEntity();
|
|
job.setId(1L);
|
|
run = new CronJobRunEntity();
|
|
run.setId(42L);
|
|
run.setStatus("succeeded");
|
|
}
|
|
|
|
@Test
|
|
void deliver_claimsSuccessfully_marksDelivered() {
|
|
// First update = the claim CAS, returns 1 (won the race)
|
|
// Second update = the markDelivered, returns 1
|
|
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
|
|
|
|
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
|
@Override public boolean supports(CronJobEntity j) { return true; }
|
|
@Override
|
|
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
|
return DeliveryOutcome.delivered("user-x");
|
|
}
|
|
};
|
|
|
|
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run);
|
|
|
|
assertEquals(DeliveryOutcome.Status.DELIVERED, outcome.status());
|
|
assertEquals("user-x", outcome.target());
|
|
verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markDelivered
|
|
}
|
|
|
|
@Test
|
|
void deliver_claimAlreadyTaken_returnsSkippedAndDoesNotInvokeDoDeliver() {
|
|
// Claim returns 0 → another listener already won the CAS
|
|
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(0);
|
|
|
|
AtomicReference<Boolean> doDeliverInvoked = new AtomicReference<>(false);
|
|
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
|
@Override public boolean supports(CronJobEntity j) { return true; }
|
|
@Override
|
|
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
|
doDeliverInvoked.set(true);
|
|
return DeliveryOutcome.delivered("never");
|
|
}
|
|
};
|
|
|
|
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run);
|
|
|
|
assertEquals(DeliveryOutcome.Status.SKIPPED, outcome.status());
|
|
assertEquals("already-claimed-by-other-instance", outcome.reason());
|
|
assertFalse(doDeliverInvoked.get(), "doDeliver must not run after a failed CAS claim");
|
|
verify(runMapper, times(1)).update(any(), any(Wrapper.class)); // only the failed claim
|
|
}
|
|
|
|
@Test
|
|
void deliver_doDeliverThrows_marksNotDeliveredAndRethrows() {
|
|
// Claim returns 1, then markNotDelivered returns 1
|
|
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
|
|
|
|
RuntimeException oops = new RuntimeException("Slack 503 Service Unavailable");
|
|
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
|
@Override public boolean supports(CronJobEntity j) { return true; }
|
|
@Override
|
|
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
|
throw oops;
|
|
}
|
|
};
|
|
|
|
RuntimeException thrown = assertThrows(RuntimeException.class,
|
|
() -> strategy.deliver(job, new AssistantMessage("hi"), run));
|
|
assertSame(oops, thrown, "exception must propagate verbatim so the listener can audit it");
|
|
verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markNotDelivered
|
|
}
|
|
|
|
@Test
|
|
void claimRun_concurrentInvocations_onlyOneSucceeds() throws Exception {
|
|
// Simulates the cluster scenario: the SQL CAS guarantees exactly one
|
|
// listener instance wins. Mock the mapper so the FIRST update() call
|
|
// returns 1, all subsequent return 0 — matches DB semantics.
|
|
Set<Integer> winnerThreadIds = java.util.Collections.synchronizedSet(new HashSet<>());
|
|
AtomicReference<Boolean> firstClaim = new AtomicReference<>(true);
|
|
when(runMapper.update(any(), any(Wrapper.class))).thenAnswer(inv -> {
|
|
// First caller wins, others lose
|
|
return firstClaim.compareAndSet(true, false) ? 1 : 0;
|
|
});
|
|
|
|
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
|
@Override public boolean supports(CronJobEntity j) { return true; }
|
|
@Override
|
|
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
|
winnerThreadIds.add((int) Thread.currentThread().threadId());
|
|
return DeliveryOutcome.delivered("winner");
|
|
}
|
|
};
|
|
|
|
int threadCount = 8;
|
|
CountDownLatch start = new CountDownLatch(1);
|
|
var pool = Executors.newFixedThreadPool(threadCount);
|
|
try {
|
|
var futures = IntStream.range(0, threadCount).mapToObj(i -> pool.submit(() -> {
|
|
start.await();
|
|
return strategy.deliver(job, new AssistantMessage("hi"), run);
|
|
})).toList();
|
|
start.countDown();
|
|
|
|
int delivered = 0;
|
|
int skipped = 0;
|
|
for (var f : futures) {
|
|
try {
|
|
DeliveryOutcome o = f.get();
|
|
if (o.status() == DeliveryOutcome.Status.DELIVERED) delivered++;
|
|
else skipped++;
|
|
} catch (ExecutionException ignored) {
|
|
// doDeliver throws are OK; counted as not-delivered
|
|
}
|
|
}
|
|
|
|
assertEquals(1, delivered,
|
|
"Exactly one winner under concurrent claim — RFC-063r §2.6.1 invariant");
|
|
assertEquals(threadCount - 1, skipped, "All others must observe SKIPPED");
|
|
assertEquals(1, winnerThreadIds.size(),
|
|
"doDeliver must execute on exactly one thread");
|
|
} finally {
|
|
pool.shutdownNow();
|
|
}
|
|
}
|
|
}
|