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.
189 lines
7.8 KiB
Java
189 lines
7.8 KiB
Java
package vip.mate.llm.service;
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.beans.factory.ObjectProvider;
|
|
import org.springframework.context.ApplicationEventPublisher;
|
|
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
|
import vip.mate.llm.failover.AvailableProviderPool;
|
|
import vip.mate.llm.failover.ProviderHealthProperties;
|
|
import vip.mate.llm.failover.ProviderHealthTracker;
|
|
import vip.mate.llm.failover.ProviderInitProbe;
|
|
import vip.mate.llm.model.Liveness;
|
|
import vip.mate.llm.model.ModelConfigEntity;
|
|
import vip.mate.llm.model.ModelProviderEntity;
|
|
import vip.mate.llm.model.ProviderInfoDTO;
|
|
import vip.mate.llm.repository.ModelProviderMapper;
|
|
|
|
import java.util.List;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
/**
|
|
* RFC-073: covers the five {@link Liveness} states surfaced through
|
|
* {@code listProviders()}. The five branches must remain orthogonal and
|
|
* mutually exclusive — the UI relies on it as a state machine.
|
|
*
|
|
* <p>Real {@link AvailableProviderPool} and {@link ProviderHealthTracker}
|
|
* (no Spring deps); {@link ProviderInitProbe} is a Mockito mock since its
|
|
* own constructor pulls the Spring context.</p>
|
|
*/
|
|
class ModelProviderServiceLivenessTest {
|
|
|
|
private ModelProviderMapper providerMapper;
|
|
private ModelConfigService modelConfigService;
|
|
private ApplicationEventPublisher eventPublisher;
|
|
private ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthProvider;
|
|
private AvailableProviderPool pool;
|
|
private ProviderHealthTracker healthTracker;
|
|
private ProviderInitProbe initProbe;
|
|
private ObjectProvider<ProviderInitProbe> initProbeProvider;
|
|
|
|
private ModelProviderService service;
|
|
|
|
@BeforeEach
|
|
@SuppressWarnings("unchecked")
|
|
void setUp() {
|
|
providerMapper = mock(ModelProviderMapper.class);
|
|
modelConfigService = mock(ModelConfigService.class);
|
|
eventPublisher = mock(ApplicationEventPublisher.class);
|
|
claudeCodeOAuthProvider = mock(ObjectProvider.class);
|
|
when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null);
|
|
pool = new AvailableProviderPool();
|
|
// failure-threshold = 1 so a single recordFailure() trips cooldown deterministically.
|
|
ProviderHealthProperties props = new ProviderHealthProperties();
|
|
props.setFailureThreshold(1);
|
|
healthTracker = new ProviderHealthTracker(props);
|
|
initProbe = mock(ProviderInitProbe.class);
|
|
initProbeProvider = mock(ObjectProvider.class);
|
|
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
|
|
|
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
|
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("LIVE: configured + probed + in pool + not in cooldown")
|
|
void liveProvider() {
|
|
seedProvider("openai", false);
|
|
when(initProbe.hasBeenProbed("openai")).thenReturn(true);
|
|
pool.add("openai");
|
|
|
|
ProviderInfoDTO dto = singleResult();
|
|
assertEquals(Liveness.LIVE, dto.getLiveness());
|
|
assertNull(dto.getUnavailableReason());
|
|
assertNull(dto.getCooldownRemainingMs());
|
|
assertTrue(dto.getAvailable(), "available must be true when LIVE and has models");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("UNCONFIGURED: cloud provider with no api key — short-circuit before pool / probe checks")
|
|
void unconfiguredProvider() {
|
|
ModelProviderEntity p = new ModelProviderEntity();
|
|
p.setProviderId("openai");
|
|
p.setName("OpenAI");
|
|
p.setIsLocal(false);
|
|
p.setIsCustom(false);
|
|
p.setRequireApiKey(true);
|
|
p.setApiKey("");
|
|
p.setBaseUrl("https://api.openai.com/v1");
|
|
when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p));
|
|
when(modelConfigService.listModels()).thenReturn(List.of());
|
|
|
|
ProviderInfoDTO dto = singleResult();
|
|
assertEquals(Liveness.UNCONFIGURED, dto.getLiveness());
|
|
// Probe should not even be consulted for unconfigured providers.
|
|
verify(initProbe, never()).hasBeenProbed("openai");
|
|
assertFalse(dto.getAvailable());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("UNPROBED: configured but probe hasn't fired yet (startup window)")
|
|
void unprobedProvider() {
|
|
seedProvider("ollama", true);
|
|
when(initProbe.hasBeenProbed("ollama")).thenReturn(false);
|
|
// pool intentionally empty — UNPROBED takes precedence over REMOVED so the UI
|
|
// can render skeletons during the startup window instead of false negatives.
|
|
|
|
ProviderInfoDTO dto = singleResult();
|
|
assertEquals(Liveness.UNPROBED, dto.getLiveness());
|
|
assertFalse(dto.getAvailable());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("REMOVED: probed and HARD-removed — reason + lastProbedAtMs populated")
|
|
void removedProvider() {
|
|
seedProvider("openai", false);
|
|
when(initProbe.hasBeenProbed("openai")).thenReturn(true);
|
|
pool.remove("openai", AvailableProviderPool.RemovalSource.AUTH_ERROR, "401 Unauthorized");
|
|
|
|
ProviderInfoDTO dto = singleResult();
|
|
assertEquals(Liveness.REMOVED, dto.getLiveness());
|
|
assertEquals("401 Unauthorized", dto.getUnavailableReason());
|
|
assertNotNull(dto.getLastProbedAtMs());
|
|
assertFalse(dto.getAvailable());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("COOLDOWN: in pool but tracker reports cooldown remaining")
|
|
void cooldownProvider() {
|
|
seedProvider("openai", false);
|
|
when(initProbe.hasBeenProbed("openai")).thenReturn(true);
|
|
pool.add("openai");
|
|
// failure-threshold = 1 → one recorded failure trips cooldown immediately.
|
|
healthTracker.recordFailure("openai");
|
|
|
|
ProviderInfoDTO dto = singleResult();
|
|
assertEquals(Liveness.COOLDOWN, dto.getLiveness());
|
|
assertNotNull(dto.getCooldownRemainingMs());
|
|
assertTrue(dto.getCooldownRemainingMs() > 0);
|
|
assertFalse(dto.getAvailable(), "cooldown is not LIVE so available must be false");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Probe-bean absent (test context with no init probe) → fall back to LIVE not UNPROBED")
|
|
void noProbeBeanFallsOpen() {
|
|
when(initProbeProvider.getIfAvailable()).thenReturn(null);
|
|
seedProvider("openai", false);
|
|
pool.add("openai");
|
|
|
|
ProviderInfoDTO dto = singleResult();
|
|
assertEquals(Liveness.LIVE, dto.getLiveness(),
|
|
"no probe bean must not strand all providers in UNPROBED forever");
|
|
}
|
|
|
|
// ============================================================
|
|
// Helpers
|
|
// ============================================================
|
|
|
|
/** Wire mapper / model service to return a single configured provider with one model. */
|
|
private void seedProvider(String id, boolean local) {
|
|
ModelProviderEntity p = new ModelProviderEntity();
|
|
p.setProviderId(id);
|
|
p.setName(id);
|
|
p.setIsLocal(local);
|
|
p.setIsCustom(false);
|
|
p.setRequireApiKey(!local);
|
|
p.setApiKey(local ? "" : "sk-test-key-1234567890");
|
|
p.setBaseUrl(local ? "http://127.0.0.1:11434" : "https://api.example.com/v1");
|
|
when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p));
|
|
|
|
ModelConfigEntity m = new ModelConfigEntity();
|
|
m.setProvider(id);
|
|
m.setModelName(id + "-model");
|
|
m.setName(id + "-model");
|
|
m.setBuiltin(true);
|
|
when(modelConfigService.listModels()).thenReturn(List.of(m));
|
|
}
|
|
|
|
private ProviderInfoDTO singleResult() {
|
|
List<ProviderInfoDTO> list = service.listProviders();
|
|
assertEquals(1, list.size());
|
|
return list.get(0);
|
|
}
|
|
}
|