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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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