diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/FakeTransport.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/FakeTransport.java new file mode 100644 index 0000000..2509437 --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/FakeTransport.java @@ -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。 + * + *
+ * FakeTransport t = FakeTransport.create()
+ *     .route("lingxing", "order/v2/list", body -> HttpResponse.of(200, null, json, 5));
+ * 
+ */ +public final class FakeTransport implements HttpTransport { + + private final List recorded = new ArrayList(); + private final List routes = new ArrayList(); + private Function fallback; + + public static FakeTransport create() { + return new FakeTransport(); + } + + /** 按 URL 子串 + 方法匹配。 */ + public FakeTransport route(String urlContains, String method, Function responder) { + routes.add(new Route(urlContains, method, responder)); + return this; + } + + public FakeTransport route(String urlContains, Function responder) { + return route(urlContains, null, responder); + } + + public FakeTransport fallback(Function 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 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 responder; + + private Route(String urlContains, String method, Function responder) { + this.urlContains = urlContains; + this.method = method; + this.responder = responder; + } + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/Fixtures.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/Fixtures.java new file mode 100644 index 0000000..994fc59 --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/Fixtures.java @@ -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; + +/** + * 夹具加载器。 + * + *

约定目录:{@code src/test/resources/fixtures/{platform}/{operation}/{case}.json}。 + * 适配器测试只依赖录制报文,不依赖网络。

+ */ +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"); + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryAccounts.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryAccounts.java new file mode 100644 index 0000000..a18b85d --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryAccounts.java @@ -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 accounts = new HashMap(); + private final Map secrets = new HashMap(); + + 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 findByKey(String provider, String platform, String omsStoreId) { + return Optional.ofNullable(accounts.get(id(provider, platform, omsStoreId))); + } + + @Override + public List findByOmsStoreId(String omsStoreId) { + List out = new ArrayList(); + 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; + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryCursorStore.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryCursorStore.java new file mode 100644 index 0000000..d9f2028 --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryCursorStore.java @@ -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 cursors = new HashMap(); + + @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; + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryLockProvider.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryLockProvider.java new file mode 100644 index 0000000..fa67ed0 --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryLockProvider.java @@ -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 held = new HashSet(); + private final Map acquireCounts = new HashMap(); + + @Override + public synchronized Optional 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.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); + } + } + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemorySink.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemorySink.java new file mode 100644 index 0000000..5431ddd --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemorySink.java @@ -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 salesOrders = new ArrayList(); + private final List products = new ArrayList(); + private int salesOrderUpsertCalls; + private int productUpsertCalls; + + @Override + public void upsertSalesOrders(Page page) { + salesOrders.addAll(page.items()); + salesOrderUpsertCalls++; + } + + @Override + public void upsertProducts(Page page) { + products.addAll(page.items()); + productUpsertCalls++; + } + + public List salesOrders() { + return Collections.unmodifiableList(salesOrders); + } + + public List products() { + return Collections.unmodifiableList(products); + } + + public int salesOrderUpsertCalls() { + return salesOrderUpsertCalls; + } + + public int productUpsertCalls() { + return productUpsertCalls; + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryTokenStore.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryTokenStore.java new file mode 100644 index 0000000..3262c23 --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/InMemoryTokenStore.java @@ -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 tokens = new HashMap(); + private int saveCount; + + @Override + public Optional 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; + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/Responses.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/Responses.java new file mode 100644 index 0000000..242383c --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/Responses.java @@ -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.emptyMap(), body, 1); + } + + public static HttpResponse status(int code, String body) { + return HttpResponse.of(code, Collections.emptyMap(), body, 1); + } + + public static HttpResponse rateLimited(String retryAfterSeconds) { + return HttpResponse.of(429, + Collections.singletonMap("Retry-After", retryAfterSeconds), "", 1); + } +} diff --git a/testkit/src/main/java/apelet/common/ecommerce/testkit/TestData.java b/testkit/src/main/java/apelet/common/ecommerce/testkit/TestData.java new file mode 100644 index 0000000..832271e --- /dev/null +++ b/testkit/src/main/java/apelet/common/ecommerce/testkit/TestData.java @@ -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 pageOf(SalesOrder... orders) { + return Page.last(java.util.Arrays.asList(orders)); + } +}