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));
+ }
+}