Browse Source
- 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
9 changed files with 409 additions and 0 deletions
@ -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 -> 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; |
||||
} |
||||
} |
||||
} |
||||
@ -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"); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue