Browse Source

feat(testkit): InMemory SPI、FakeTransport 与夹具加载

- FakeTransport:按 URL 子串+方法路由,记录全部请求,可直接返回录制响应
- InMemory*:Accounts/TokenStore/CursorStore/Sink/LockProvider
- Fixtures:fixtures/{platform}/{operation}/{case}.json 约定加载
- Responses/TestData:响应与同步测试的快捷构造

目标:适配器单测不依赖网络、数据库、Spring。

Co-Authored-By: Claude <noreply@anthropic.com>
master
chenchuchuan 19 hours ago
parent
commit
0f929a6be3
  1. 85
      testkit/src/main/java/apelet/common/ecommerce/testkit/FakeTransport.java
  2. 36
      testkit/src/main/java/apelet/common/ecommerce/testkit/Fixtures.java
  3. 59
      testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryAccounts.java
  4. 37
      testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryCursorStore.java
  5. 52
      testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryLockProvider.java
  6. 47
      testkit/src/main/java/apelet/common/ecommerce/testkit/InMemorySink.java
  7. 36
      testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryTokenStore.java
  8. 25
      testkit/src/main/java/apelet/common/ecommerce/testkit/Responses.java
  9. 32
      testkit/src/main/java/apelet/common/ecommerce/testkit/TestData.java

85
testkit/src/main/java/apelet/common/ecommerce/testkit/FakeTransport.java

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.http.HttpRequest;
import apelet.common.ecommerce.http.HttpResponse;
import apelet.common.ecommerce.http.HttpTransport;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
/**
* 可编程传输。适配器单测直接返回录制的响应,不需要网络或 mock server。
*
* <pre>
* FakeTransport t = FakeTransport.create()
* .route("lingxing", "order/v2/list", body -&gt; HttpResponse.of(200, null, json, 5));
* </pre>
*/
public final class FakeTransport implements HttpTransport {
private final List<HttpRequest> recorded = new ArrayList<HttpRequest>();
private final List<Route> routes = new ArrayList<Route>();
private Function<HttpRequest, HttpResponse> fallback;
public static FakeTransport create() {
return new FakeTransport();
}
/** 按 URL 子串 + 方法匹配。 */
public FakeTransport route(String urlContains, String method, Function<HttpRequest, HttpResponse> responder) {
routes.add(new Route(urlContains, method, responder));
return this;
}
public FakeTransport route(String urlContains, Function<HttpRequest, HttpResponse> responder) {
return route(urlContains, null, responder);
}
public FakeTransport fallback(Function<HttpRequest, HttpResponse> responder) {
this.fallback = responder;
return this;
}
@Override
public HttpResponse execute(HttpRequest request) {
recorded.add(request);
for (Route r : routes) {
if (request.url().contains(r.urlContains)
&& (r.method == null || r.method.equalsIgnoreCase(request.method()))) {
return r.responder.apply(request);
}
}
if (fallback != null) {
return fallback.apply(request);
}
throw new IllegalStateException("no fake route for " + request.method() + " " + request.url());
}
public List<HttpRequest> recorded() {
return Collections.unmodifiableList(recorded);
}
/** 最后一次请求;无请求时返回 null。 */
public HttpRequest lastRequest() {
return recorded.isEmpty() ? null : recorded.get(recorded.size() - 1);
}
public void clear() {
recorded.clear();
}
private static final class Route {
private final String urlContains;
private final String method;
private final Function<HttpRequest, HttpResponse> responder;
private Route(String urlContains, String method, Function<HttpRequest, HttpResponse> responder) {
this.urlContains = urlContains;
this.method = method;
this.responder = responder;
}
}
}

36
testkit/src/main/java/apelet/common/ecommerce/testkit/Fixtures.java

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
package apelet.common.ecommerce.testkit;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
/**
* 夹具加载器。
*
* <p>约定目录:{@code src/test/resources/fixtures/{platform}/{operation}/{case}.json}。
* 适配器测试只依赖录制报文,不依赖网络。</p>
*/
public final class Fixtures {
private Fixtures() {
}
public static String load(Class<?> anchor, String path) {
String resource = path.startsWith("/") ? path : "/" + path;
try (InputStream in = anchor.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("fixture not found: " + resource);
}
Scanner scanner = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A");
return scanner.hasNext() ? scanner.next() : "";
} catch (IOException e) {
throw new IllegalStateException("read fixture failed: " + resource, e);
}
}
/** 按约定拼路径。 */
public static String load(Class<?> anchor, String platform, String operation, String caseName) {
return load(anchor, "fixtures/" + platform + "/" + operation + "/" + caseName + ".json");
}
}

59
testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryAccounts.java

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.spi.ChannelAccountRepository;
import apelet.common.ecommerce.spi.ChannelSecret;
import apelet.common.ecommerce.model.ChannelAccount;
import apelet.common.ecommerce.spi.SecretProvider;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** 内存版账户仓库与凭证提供者。 */
public final class InMemoryAccounts implements ChannelAccountRepository, SecretProvider {
private final Map<String, ChannelAccount> accounts = new HashMap<String, ChannelAccount>();
private final Map<String, ChannelSecret> secrets = new HashMap<String, ChannelSecret>();
public InMemoryAccounts put(ChannelAccount account) {
accounts.put(id(account.provider().name(), account.platform().name(), account.omsStoreId()), account);
return this;
}
public InMemoryAccounts putSecret(String credentialRef, ChannelSecret secret) {
secrets.put(credentialRef, secret);
return this;
}
@Override
public Optional<ChannelAccount> findByKey(String provider, String platform, String omsStoreId) {
return Optional.ofNullable(accounts.get(id(provider, platform, omsStoreId)));
}
@Override
public List<ChannelAccount> findByOmsStoreId(String omsStoreId) {
List<ChannelAccount> out = new ArrayList<ChannelAccount>();
for (ChannelAccount a : accounts.values()) {
if (a.omsStoreId().equals(omsStoreId)) {
out.add(a);
}
}
return Collections.unmodifiableList(out);
}
@Override
public ChannelSecret get(String credentialRef) {
ChannelSecret s = secrets.get(credentialRef);
if (s == null) {
throw new IllegalStateException("no fake secret for " + credentialRef);
}
return s;
}
private static String id(String provider, String platform, String omsStoreId) {
return provider + '|' + platform + '|' + omsStoreId;
}
}

37
testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryCursorStore.java

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.model.ChannelProvider;
import apelet.common.ecommerce.spi.SyncCursorStore;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
/** 内存版水位线存储。 */
public final class InMemoryCursorStore implements SyncCursorStore {
private final Map<String, Instant> cursors = new HashMap<String, Instant>();
@Override
public Instant get(ChannelProvider provider, String accountKey, String stream) {
return cursors.get(id(provider, accountKey, stream));
}
@Override
public void advance(ChannelProvider provider, String accountKey, String stream, Instant watermark) {
cursors.put(id(provider, accountKey, stream), watermark);
}
public void seed(ChannelProvider provider, String accountKey, String stream, Instant watermark) {
advance(provider, accountKey, stream, watermark);
}
/** 断言用:读取当前水位线。 */
public Instant watermark(ChannelProvider provider, String accountKey, String stream) {
return get(provider, accountKey, stream);
}
private static String id(ChannelProvider provider, String accountKey, String stream) {
return provider.name() + '|' + accountKey + '|' + stream;
}
}

52
testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryLockProvider.java

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.spi.LockProvider;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** 单线程内存锁,足以验证防击穿分支。 */
public final class InMemoryLockProvider implements LockProvider {
private final Set<String> held = new HashSet<String>();
private final Map<String, Integer> acquireCounts = new HashMap<String, Integer>();
@Override
public synchronized Optional<Lock> tryLock(String key, long ttlSeconds) {
if (held.contains(key)) {
return Optional.empty();
}
held.add(key);
Integer c = acquireCounts.get(key);
acquireCounts.put(key, c == null ? 1 : c + 1);
return Optional.<Lock>of(new Handle(key));
}
public synchronized int acquireCount(String key) {
Integer c = acquireCounts.get(key);
return c == null ? 0 : c;
}
public synchronized boolean isHeld(String key) {
return held.contains(key);
}
private final class Handle implements Lock {
private final String key;
private Handle(String key) {
this.key = key;
}
@Override
public void close() {
synchronized (InMemoryLockProvider.this) {
held.remove(key);
}
}
}
}

47
testkit/src/main/java/apelet/common/ecommerce/testkit/InMemorySink.java

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.model.Page;
import apelet.common.ecommerce.model.ProductListing;
import apelet.common.ecommerce.model.SalesOrder;
import apelet.common.ecommerce.spi.Sink;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** 内存版落库 Sink,记录每次 upsert。 */
public final class InMemorySink implements Sink {
private final List<SalesOrder> salesOrders = new ArrayList<SalesOrder>();
private final List<ProductListing> products = new ArrayList<ProductListing>();
private int salesOrderUpsertCalls;
private int productUpsertCalls;
@Override
public void upsertSalesOrders(Page<SalesOrder> page) {
salesOrders.addAll(page.items());
salesOrderUpsertCalls++;
}
@Override
public void upsertProducts(Page<ProductListing> page) {
products.addAll(page.items());
productUpsertCalls++;
}
public List<SalesOrder> salesOrders() {
return Collections.unmodifiableList(salesOrders);
}
public List<ProductListing> products() {
return Collections.unmodifiableList(products);
}
public int salesOrderUpsertCalls() {
return salesOrderUpsertCalls;
}
public int productUpsertCalls() {
return productUpsertCalls;
}
}

36
testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryTokenStore.java

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.spi.AccessToken;
import apelet.common.ecommerce.spi.TokenStore;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/** 内存版令牌存储。 */
public final class InMemoryTokenStore implements TokenStore {
private final Map<String, AccessToken> tokens = new HashMap<String, AccessToken>();
private int saveCount;
@Override
public Optional<AccessToken> get(String tokenKey) {
return Optional.ofNullable(tokens.get(tokenKey));
}
@Override
public void save(String tokenKey, AccessToken token) {
tokens.put(tokenKey, token);
saveCount++;
}
@Override
public void remove(String tokenKey) {
tokens.remove(tokenKey);
}
/** 刷新写入次数,用于断言「防击穿只刷新一次」。 */
public int saveCount() {
return saveCount;
}
}

25
testkit/src/main/java/apelet/common/ecommerce/testkit/Responses.java

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.http.HttpResponse;
import java.util.Collections;
/** 快捷构造响应。 */
public final class Responses {
private Responses() {
}
public static HttpResponse ok(String body) {
return HttpResponse.of(200, Collections.<String, String>emptyMap(), body, 1);
}
public static HttpResponse status(int code, String body) {
return HttpResponse.of(code, Collections.<String, String>emptyMap(), body, 1);
}
public static HttpResponse rateLimited(String retryAfterSeconds) {
return HttpResponse.of(429,
Collections.singletonMap("Retry-After", retryAfterSeconds), "", 1);
}
}

32
testkit/src/main/java/apelet/common/ecommerce/testkit/TestData.java

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
package apelet.common.ecommerce.testkit;
import apelet.common.ecommerce.model.Page;
import apelet.common.ecommerce.model.SalesOrder;
import apelet.common.ecommerce.spi.SyncCursorStore;
import java.time.Duration;
import java.time.Instant;
/** 测试夹具:常用账户与同步维度。 */
public final class TestData {
private TestData() {
}
public static Instant utc(String iso) {
return Instant.parse(iso);
}
public static Duration overlapMinutes(int minutes) {
return Duration.ofMinutes(minutes);
}
/** 已推进过的水位线,避免测试里每次走全量。 */
public static void seedWatermark(SyncCursorStore store, apelet.common.ecommerce.sync.SyncSpec spec, String iso) {
store.advance(spec.provider(), spec.accountKey(), spec.stream(), Instant.parse(iso));
}
public static Page<SalesOrder> pageOf(SalesOrder... orders) {
return Page.last(java.util.Arrays.asList(orders));
}
}
Loading…
Cancel
Save