diff --git a/llm/wechat-subsystem-test-matrix.md b/llm/wechat-subsystem-test-matrix.md
new file mode 100644
index 0000000..43ad844
--- /dev/null
+++ b/llm/wechat-subsystem-test-matrix.md
@@ -0,0 +1,359 @@
+# WeChat Subsystem — Characterization / Integration Test Matrix
+
+This document is a **behavior pin** (characterization) test matrix for the current WeChat-related subsystem.
+The goal is to lock observable behavior (HTTP + DB + Redis + external calls) so later refactoring can be done safely.
+
+Repo root: `/Volumes/main/code/yunpei/peipei-backend`
+
+## Source Inventory (entry points)
+
+### Controllers
+
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPlayController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCommonController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOrderInfoController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCouponController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkMediaController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxBlindBoxController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPkController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxGiftController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkCommodityController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCommodityController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxLevelController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxShopController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkWagesController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPersonnelGroupInfoController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPlayOrderRankingController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawPayeeController.java`
+
+### Services / Cross-cutting
+
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxOauthService.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomMpService.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxTokenService.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomUserService.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxBlindBoxOrderService.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/security/filter/JwtAuthenticationTokenFilter.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/aspect/CustomUserLoginAspect.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/aspect/ClerkUserLoginAspect.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/NotificationSender.java`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MockNotificationSender.java`
+
+### DB schema touchpoints
+
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/resources/db/migration/V1__init_schema.sql`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/resources/db/migration/V17__create_media.sql`
+
+Key fields to pin:
+
+- `sys_tenant`: `tenant_key`, `app_id`, `secret`, `mch_id`, `mch_key`, `*_template_id`, `profitsharing_rate`
+- `play_custom_user_info`: `openid`, `unionid`, `wei_chat_code`, `token`
+- `play_clerk_user_info`: `openid` (NOT NULL), `wei_chat_code`, `token`, `online_state`, `onboarding_state`, `listing_state`, `clerk_state`
+- `play_order_info`: `order_type`, `pay_method`, `pay_state`, `place_type`, `reward_type`, `wei_chat_code`, `profit_sharing_amount`
+- `play_media` / `play_clerk_media_asset`: `status`, `usage`, `review_state`, `deleted`, `order_index` + unique constraint `uk_clerk_usage_media`
+
+## Test Harness Guidelines
+
+### Required runtime surfaces to observe
+
+1) **HTTP**: status code + response schema (normalized) for every `/wx/**` route.
+2) **DB**: before/after snapshots of rows touched by the route (focus tables above).
+3) **Redis**: keys under:
+ - `TENANT_INFO:*`
+ - `login_codes:*`
+ - PK keys under `PkRedisKeyConstants` (if enabled)
+4) **External calls**: record interactions with:
+ - `WxMpService` / WeChat MP template message service
+ - `WxPayService` (unified order + profitsharing)
+ - `IOssFileService`
+ - `SmsUtils`
+ - background tasks like `OverdueOrderHandlerTask`
+
+### Profiles
+
+- Prefer running integration tests under `apitest` profile (seeded DB + mock notifier).
+- For tests that must hit real async behavior, use deterministic executors in test config (or replace `ThreadPoolTaskExecutor` with inline executor via `@MockBean`).
+
+### Normalization rules (avoid flaky tests)
+
+- Normalize dynamic fields: timestamps, random IDs (UUID), and generated nonces.
+- For money: always compare as `BigDecimal` with explicit scale/rounding.
+- For lists: assert stable ordering only when the endpoint guarantees ordering.
+
+---
+
+## 1) Gateway / Tenant / AuthN (Filter + AOP)
+
+| ID | Surface | Route / Component | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| GW-001 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/**` without `tenantkey` header on a login-required route | none | none | response is error (pin: code/body shape + status) |
+| GW-002 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/**` without `tenantkey` on a no-login whitelist route | none | none | response is error (pin: JSON body written by filter) |
+| GW-003 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/pay/jsCallback` bypasses auth and does not require `tenantkey` | none | none | handler executes and returns success string |
+| GW-004 | HTTP/Redis | filter `getTenantId` | with `tenantkey` only (no token) | tenant exists | `ISysTenantService` real or stub | `SecurityUtils.getTenantId()` set; request proceeds |
+| GW-005 | HTTP/Redis | filter `getTenantId` | with clerk token | seed clerk with token + tenantId | none | tenantId resolved from DB; `TENANT_INFO:{userId}` read does not break flow |
+| GW-006 | HTTP/Redis | filter `getTenantId` | with custom token | seed customer with token + tenantId | none | tenantId resolved from DB |
+| AOP-001 | HTTP | `@CustomUserLogin` endpoints | missing `customusertoken` header | none | none | 401 `token为空` |
+| AOP-002 | HTTP | `@ClerkUserLogin` endpoints | missing `clerkusertoken` header | none | none | 401 `token为空` |
+| AOP-003 | HTTP | `CustomUserLoginAspect` | invalid token format/signature | none | none | 401 `获取用户信息异常` |
+| AOP-004 | HTTP | `CustomUserLoginAspect` | token ok but DB token mismatch | seed user with different token | none | 401 `token异常` |
+| AOP-005 | HTTP | `ClerkUserLoginAspect` | `ensureClerkSessionIsValid` rejects | seed clerk state invalid | none | 401 message matches current `CustomException` message |
+| TOK-001 | unit/contract | `WxTokenService` | token generated and parsed (with/without `Bearer `) | config secret + expireTime | none | same userId extracted |
+| TOK-002 | unit/contract | `WxTokenService` | expired token fails | short expireTime | none | parsing throws; upstream maps to 401 |
+
+---
+
+## 2) WeChat OAuth (WxOauthController + WxOauthService)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| OAUTH-001 | HTTP | `POST /wx/oauth2/getConfigAddress` | `url` omitted -> uses default callback | valid tenant | mock `WxMpService` signature | signature returned; default URL pinned |
+| OAUTH-002 | HTTP | `POST /wx/oauth2/getConfigAddress` | `url` provided -> overrides default | valid tenant | mock signature | uses provided URL |
+| OAUTH-003 | HTTP | `POST /wx/oauth2/getClerkLoginAddress` | returns auth URL with scope `snsapi_userinfo` | valid tenant | mock OAuth2 service build URL | response contains expected scope |
+| OAUTH-004 | HTTP | `POST /wx/oauth2/getCustomLoginAddress` | same as clerk | valid tenant | mock OAuth2 service | response contains expected scope |
+| OAUTH-005 | HTTP/DB/Redis | `POST /wx/oauth2/custom/login` | success -> returns token payload + persists token | seed tenant + seeded customer openid | mock `WxMpService.getOAuth2Service().getAccessToken/getUserInfo` | response includes `tokenValue/tokenName/loginDate`; DB token updated; Redis `TENANT_INFO:{id}` set |
+| OAUTH-006 | HTTP | `POST /wx/oauth2/custom/login` | upstream WeChat oauth fails -> unauthorized | none | mock `WxMpService.getOAuth2Service().getAccessToken` to throw | response `code=401` (and `success=false`) |
+| OAUTH-007 | HTTP/DB/Redis | `POST /wx/oauth2/clerk/login` | success -> token persisted + tenant cached; `pcData` present | seed a clerk with `sysUserId=""` to avoid PC login dependency | mock `WxMpService.getOAuth2Service().getAccessToken/getUserInfo` | response includes `pcData.token/role` (empty strings); DB token updated; Redis `TENANT_INFO:{id}` set |
+| OAUTH-008 | HTTP/DB | `GET /wx/oauth2/custom/logout` | token invalidated | seed user + token | none | DB token set to `empty`; subsequent access 401 |
+| OAUTH-009 | service/DB | `WxOauthService.customUserLogin` | first login creates new user with registrationTime | empty DB | mock MP OAuth2 | row inserted with expected fields |
+| OAUTH-010 | service/DB | `WxOauthService.clerkUserLogin` | deleted clerk restored | seed clerk deleted=true | mock MP OAuth2 | deleted=false; default states filled; token/online reset |
+| OAUTH-011 | HTTP | `GET /wx/oauth2/checkSubscribe` | returns boolean subscribe state | logged-in customer | mock `WxMpService.getUserService().userInfo` | response `data` is `true/false` |
+
+---
+
+## 3) WeChat Pay Recharge + Callback + Profit Sharing (WxPlayController)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| PAY-001 | HTTP | `GET /wx/pay/custom/getCustomPaymentAmount` | money empty -> 500 | logged-in customer | none | error message pinned |
+| PAY-002 | HTTP | `GET /wx/pay/custom/getCustomPaymentAmount` | money valid -> BigDecimal returned | logged-in customer | none | response number pinned (scale/rounding) |
+| PAY-003 | HTTP/DB | `GET /wx/pay/custom/createOrder` | money < 1 -> 500 | logged-in customer | none | error message pinned |
+| PAY-004 | HTTP/DB | `GET /wx/pay/custom/createOrder` | creates recharge order + returns JSAPI pay params | logged-in customer + tenant with mch config | mock `WxPayService.unifiedOrder` and `SignUtils` inputs via capturing request | order row created (type/pay_state); response has required fields; unifiedOrder request fields pinned |
+| PAY-005 | HTTP | `GET /wx/pay/custom/createOrder` | subscribe check fails -> error | customer openid + tenant | mock `WxCustomMpService.checkSubscribeThrowsExp` to throw | HTTP error pinned |
+| PAY-006 | HTTP/DB | `POST/GET /wx/pay/jsCallback` | invalid XML -> still returns success string, no DB changes | existing DB | none | response equals success; no order updates |
+| PAY-007 | HTTP/DB | `/wx/pay/jsCallback` | unknown out_trade_no -> no changes | none | none | no DB changes |
+| PAY-008 | HTTP/DB | `/wx/pay/jsCallback` | order_type!=0 OR pay_state!=0 -> no reprocessing | seed paid/non-recharge order | none | no balance change; no state change |
+| PAY-009 | HTTP/DB/Redis | `/wx/pay/jsCallback` | happy path updates pay_state and balance | seed recharge order pay_state=0 + tenant attach | mock `customAccountBalanceRecharge` if needed or assert real side-effects | pay_state becomes `1`; balance updated; template message called |
+| PAY-010 | HTTP/DB | `/wx/pay/jsCallback` | replay same callback twice is idempotent | seed recharge order | none | balance does not double-add; profit_sharing_amount not duplicated |
+| PAY-011 | HTTP/DB | profitSharing | profitsharing_rate<=0 -> no call and no write | tenant rate=0 | mock WxPay profitSharing service | no API call; no DB update |
+| PAY-012 | HTTP/DB | profitSharing | rate>0 and computed amount=0 -> no call | tiny amount | mock | no call |
+| PAY-013 | HTTP/DB | profitSharing | rate>0 -> writes profit_sharing_amount | tenant rate set | mock profitSharing result | DB field set to expected BigDecimal |
+
+---
+
+## 4) WeChat MP Notifications (WxCustomMpService)
+
+| ID | Surface | Component | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| MP-001 | unit/contract | `proxyWxMpService` | missing tenantId -> CustomException | tenantId unset | none | exception message pinned |
+| MP-002 | unit/contract | `getWxPay` | tenant missing mch_id -> CustomException | tenant has empty mchId | none | message pinned |
+| MP-003 | integration | `sendCreateOrderMessage` | template data fields mapping | tenant has templateId + tenantKey | mock template msg service | `short_thing5` label resolved; URL pinned |
+| MP-004 | integration | `sendCreateOrderMessageBatch` | filters offboarded/delisted clerks | clerk list mix | deterministic executor + mock template | only eligible clerks called |
+| MP-005 | integration | `sendBalanceMessage` | sends recharge success template | order + tenant + customer | mock template | data keys pinned |
+| MP-006 | integration | `sendOrderFinishMessage` | only placeType "1"/"2" triggers | orders with other placeType | mock template | no calls when not matched |
+| MP-007 | integration | async wrappers | exceptions inside async do not bubble | throw in underlying send | deterministic executor | caller does not fail |
+| MP-008 | integration | subscribe checks | subscribe=false -> ServiceException message pinned | mock `WxMpUser.subscribe=false` | mock user service | message pinned |
+
+---
+
+## 5) Common WeChat Tools (WxCommonController + WxFileUtils)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| COM-001 | HTTP | `GET /wx/common/area/tree` | returns area tree | tenantKey only | mock area service or real | response schema pinned |
+| COM-002 | HTTP | `GET /wx/common/setting/info` | returns global UI config (NOT tenant-scoped) | call with two different `X-Tenant` values | real service | both responses share same `data.id` |
+| COM-003 | HTTP | `POST /wx/common/file/upload` | uploads to OSS returns URL | multipart file | mock `IOssFileService.upload` | returned URL pinned |
+| COM-004 | HTTP | `GET /wx/common/audio/upload` | mediaId empty -> error | none | none | error message pinned |
+| COM-005 | HTTP | `GET /wx/common/audio/upload` | successful path uploads mp3 | provide accessToken + temp audio stream | mock `WxAccessTokenService`, stub `WxFileUtils.getTemporaryMaterial` (via wrapper or test seam), mock OSS | returns URL; temp files cleaned |
+| COM-006 | unit/contract | `WxFileUtils.audioConvert2Mp3` | invalid source -> throws | empty file | none | error message pinned |
+
+---
+
+## 6) Customer (WxCustomController) — Orders, Gifts, Complaints, Follow, Leave Msg
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| CUS-001 | HTTP | `GET /wx/custom/queryClerkDetailedById` | not logged in still works | tenantKey only | none | response pinned (no crash) |
+| CUS-002 | HTTP/DB | `GET /wx/custom/queryById` | returns clerkState=1 when openid matches clerk | seed custom+clerk share openid | none | response `clerkState` pinned |
+| CUS-003 | HTTP/DB | `POST /wx/custom/updateHideLevelState` | ignores client id and uses session id | logged-in custom | none | DB update on session row only |
+| CUS-004 | HTTP/DB | `POST /wx/custom/order/reward` | creates completed reward order | logged-in custom + balance | none | order row fields pinned; ledger/balance delta pinned |
+| CUS-005 | HTTP/DB | `POST /wx/custom/order/gift` | calls WxGiftOrderService and increments gift counts | logged-in custom + gift seeded | none | order created; both gift counters incremented |
+| CUS-006 | HTTP/DB | `POST /wx/custom/order/commodity` | creates specified order pending | logged-in custom + clerk + commodity | mock pricing if needed | order fields pinned |
+| CUS-007 | HTTP/DB | `POST /wx/custom/order/random` | success triggers notification + overdue task | logged-in custom + clerks eligible | mock `WxCustomMpService`, mock `OverdueOrderHandlerTask` | expected calls + order fields pinned |
+| CUS-008 | HTTP/DB | `POST /wx/custom/order/random` | insufficient balance fails and no order created | set balance=0 | none | error code pinned; no DB insert |
+| CUS-009 | HTTP | `POST /wx/custom/order/queryByPage` | only returns self purchaserBy | seed orders for multiple users | none | result contains only self |
+| CUS-010 | HTTP/DB | `GET /wx/custom/order/end` | state transition invoked | seed order | none | order status moved (pin) |
+| CUS-011 | HTTP/DB | `POST /wx/custom/order/cancellation` | pins cancellation refund record (images ignored) | seed pending/accepted order + send non-empty images | none | order canceled; refund record has `refundReason`; `images` stays null; order `refundReason` stays null |
+| CUS-012 | HTTP/DB | `POST /wx/custom/order/evaluate/add` | non-purchaser cannot evaluate | order purchaser different | none | error message pinned |
+| CUS-013 | HTTP | `GET /wx/custom/order/evaluate/queryByOrderId` | not evaluated -> error | seed order no eval | none | `当前订单未评价` |
+| CUS-014 | HTTP/DB | `POST /wx/custom/order/complaint/add` | non-purchaser cannot complain | order purchaser different | none | error message pinned |
+| CUS-015 | HTTP/DB | `POST /wx/custom/leave/add` | creates leave msg; response message pinned | logged-in custom | none | DB insert; response message currently `"取消成功"` |
+| CUS-016 | HTTP | `GET /wx/custom/leave/queryPermission` | permission true/false schema pinned | set conditions | none | response JSON has `permission` boolean and `msg` |
+| CUS-017 | HTTP/DB | `POST /wx/custom/followState/update` | idempotency and correctness | seed follow relation | none | follow state pinned |
+| CUS-018 | HTTP | `POST /wx/custom/follow/queryByPage` | paging/filters pinned | seed relations | none | page schema pinned |
+
+---
+
+## 7) Clerk (WxClerkController) — Apply, Profile Review, Order Ops, Privacy Rules
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| CLK-001 | HTTP | `POST /wx/clerk/user/queryPerformanceInfo` | date normalize behavior pinned | seed orders | none | output stable for same input |
+| CLK-002 | HTTP | `GET /wx/clerk/user/queryLevelInfo` | hardcoded `levelAndRanking` list pinned | logged-in clerk | none | list size/content pinned |
+| CLK-003 | HTTP/Redis | `POST /wx/clerk/user/sendCode` | writes redis key with TTL and returns code | logged-in clerk | none | redis key format + TTL; response contains code |
+| CLK-004 | HTTP/Redis/DB | `POST /wx/clerk/user/bindCode` | wrong code -> error | seed redis code | none | `验证码错误` |
+| CLK-005 | HTTP/Redis/DB | `POST /wx/clerk/user/bindCode` | success updates phone and clears redis | seed redis code | none | DB phone updated; redis deleted |
+| CLK-006 | HTTP/DB | `POST /wx/clerk/user/add` | already clerk -> error | seed clerkState=1 | none | message pinned |
+| CLK-007 | HTTP/DB | `POST /wx/clerk/user/add` | already has pending review -> error | seed reviewState=0 | none | message pinned |
+| CLK-008 | HTTP | `POST /wx/clerk/user/add` | subscribe required | mock subscribe=false | mock `WxCustomMpService.checkSubscribeThrowsExp` | error message pinned |
+| CLK-009 | HTTP/DB | `POST /wx/clerk/user/updateNickname` | creates data review row with correct type and content | logged-in clerk | none | DB insert pinned |
+| CLK-010 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | empty album -> error | logged-in clerk | none | `最少上传一张照片` |
+| CLK-011 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | invalid new media -> error | seed play_media with mismatched owner/tenant/status | none | error message pinned |
+| CLK-012 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | legacy IDs not found -> should not fail (current behavior) | album includes missing IDs | none | request succeeds; review content contains legacy strings |
+| CLK-013 | HTTP/DB | `GET /wx/clerk/order/queryById` | privacy: non-owner clears weiChatCode | seed order acceptBy other clerk | none | weiChatCode empty |
+| CLK-014 | HTTP/DB | `GET /wx/clerk/order/queryById` | canceled order clears weiChatCode | seed orderStatus=4 | none | weiChatCode empty |
+| CLK-015 | HTTP | `GET /wx/clerk/order/accept` | subscribe required | mock subscribe=false | mock `WxCustomMpService` | fails |
+| CLK-016 | HTTP/DB | `GET /wx/clerk/order/start` | state transition pinned | seed order | none | state updated |
+| CLK-017 | HTTP/DB | `POST /wx/clerk/order/complete` | sysUserId missing -> error | seed clerk no sysUserId | none | message pinned |
+| CLK-018 | HTTP/DB | `POST /wx/clerk/order/complete` | permission resolution pinned (admin vs group leader) | seed sysUser mapping | none | correct operatorType chosen or error |
+| CLK-019 | HTTP/DB | `POST /wx/clerk/order/cancellation` | cancellation state pinned | seed order | none | status/cancel fields updated |
+| CLK-020 | HTTP | `POST /wx/clerk/user/queryEvaluateByPage` | forces hidden=VISIBLE | seed hidden evaluations | none | response excludes hidden |
+
+---
+
+## 8) Orders (WxOrderInfoController) — Continuation + Random Order Masking
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| ORD-001 | HTTP/DB | `POST /wx/order/clerk/continue` | non-owner cannot continue | order acceptBy != clerk | none | message pinned |
+| ORD-002 | HTTP/DB | `POST /wx/order/clerk/continue` | second continuation blocked | seed continue record | none | message pinned |
+| ORD-003 | HTTP | `GET /wx/order/clerk/selectRandomOrderById` | masking for non-owner pinned | seed random order accepted by other | none | fields blanked as implemented |
+| ORD-004 | HTTP/DB | `POST /wx/order/custom/updateReviewState` | reviewedState != 0 -> error | seed reviewedState=1 | none | `续单已处理` |
+| ORD-005 | HTTP/DB | `POST /wx/order/custom/continueListByPage` | customId forced to session | seed multiple users | none | only self results |
+
+---
+
+## 9) Coupons (WxCouponController)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| CP-001 | HTTP | `GET /wx/coupon/custom/obtainCoupon` | id empty -> error message pinned | logged-in custom | none | error message pinned |
+| CP-002 | HTTP/DB | obtain coupon | not eligible -> returns reason | seed coupon restrictions | none | response contains reason |
+| CP-003 | HTTP/DB | obtain coupon | eligible -> claim succeeds | seed coupon inventory | none | coupon_details inserted; response success |
+| CP-004 | HTTP | query all | whitelist hides coupons from non-whitelisted | seed coupon whitelist | none | coupon not present |
+| CP-005 | HTTP | query by order | clerkId+levelId both empty -> error | none | none | message pinned |
+| CP-006 | HTTP/DB | query by order | unavailable coupon includes reasonForUnavailableUse | seed coupon restrictions | none | available=0 and reason set |
+| CP-007 | HTTP/DB | query by order | exceptions inside loop are swallowed (current behavior) | seed one broken coupon | none | endpoint still returns 200 with remaining coupons |
+
+---
+
+## 10) Media (WxClerkMediaController + MediaUploadService)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| MED-001 | HTTP | `POST /wx/clerk/media/upload` | missing file -> error | logged-in clerk | none | message pinned |
+| MED-002 | HTTP/DB | upload image | creates `play_media` + `play_clerk_media_asset` | image multipart | mock OSS | DB rows inserted; kind=image; owner=clerk |
+| MED-003 | HTTP/DB | upload video too large | exceeds 30MB -> error | video > limit | none | error message pinned |
+| MED-004 | HTTP/DB | upload video too long | duration>45s -> error | long video | none | message pinned |
+| MED-005 | HTTP/DB | `PUT /wx/clerk/media/order` | distinct mediaIds and ordering pinned | seed assets | none | order_index updates pinned; duplicates removed |
+| MED-006 | HTTP/DB | `DELETE /wx/clerk/media/{id}` | marks `review_state=rejected` and sets `play_media.status=rejected` (asset `deleted` stays `0` currently) | seed media + asset | none | asset `review_state` becomes rejected; `play_media.status` becomes rejected; `asset.deleted` remains `0` |
+| MED-007 | HTTP | `GET /wx/clerk/media/list` | returns only draft/pending/rejected | seed assets states | none | filtering pinned |
+| MED-008 | HTTP | `GET /wx/clerk/media/approved` | returns only approved | seed assets | none | filtering pinned |
+| MED-009 | DB constraint | `uk_clerk_usage_media` | duplicate submit behavior pinned | seed duplicate row | none | error or ignore (pin current) |
+
+---
+
+## 11) Blind Box (WxBlindBoxController + WxBlindBoxOrderService)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| BB-001 | HTTP | `GET /wx/blind-box/config/list` | not logged in -> error | none | none | message pinned |
+| BB-002 | HTTP | list configs | only active configs for tenant | seed configs | none | list content pinned |
+| BB-003 | HTTP/DB | `POST /wx/blind-box/order/purchase` | tenant mismatch -> “not found” (TenantLine current behavior) | config tenant != user tenant | none | message pinned (`盲盒不存在`) |
+| BB-004 | HTTP/DB | purchase | creates completed order + reward | seed config + balance | none | order type pinned; reward row created |
+| BB-005 | HTTP/DB | `POST /wx/blind-box/reward/{id}/dispatch` | reward not found -> error | none | none | message pinned |
+| BB-006 | HTTP/DB | dispatch | status transition pinned | seed reward | none | status updated; response view pinned |
+| BB-007 | HTTP | `GET /wx/blind-box/reward/list` | status filter pinned | seed rewards | none | filter results pinned |
+| BB-008 | HTTP/DB | `POST /wx/blind-box/order/purchase` | insufficient balance -> error and no new order | customer balance < config price | none | message pinned; no new order inserted |
+
+---
+
+## 12) PK (WxPkController)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| PK-001 | HTTP | `GET /wx/pk/clerk/live` | clerkId missing -> error | none | none | message pinned |
+| PK-002 | HTTP | live | no pk -> inactive dto | seed none | none | returns inactive dto |
+| PK-003 | HTTP | live | pk exists but status!=IN_PROGRESS -> inactive | seed pk | none | inactive |
+| PK-004 | HTTP/Redis | upcoming | tenant missing -> error | no tenant context | none | message pinned |
+| PK-005 | HTTP/Redis | upcoming | redis hit/miss produces stable behavior | seed redis keys | none | response pinned |
+| PK-006 | HTTP | schedule/history | limit/page normalization pinned | none | none | safeLimit behavior pinned |
+
+---
+
+## 13) Shop + Articles + Misc
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| SHOP-001 | HTTP | `GET /wx/shop/custom/getShopHomeCarouseInfo` | returns carousel list | seed carousel | none | mapping pinned |
+| SHOP-002 | HTTP/DB | `GET /wx/shop/clerk/readShopArticleInfo` | visitsNumber increments | seed article | none | visitsNumber +1 persisted |
+| ART-001 | HTTP/DB | `POST /wx/article/clerk/add` | creates article with clerkId from session | logged-in clerk | none | DB insert pinned |
+| ART-002 | HTTP/DB | `GET /wx/article/clerk/deleteById` | deletes clerk article + custom article links | seed both | none | rows removed/soft-deleted pinned |
+| ART-003 | HTTP/DB | `POST /wx/article/custom/updateGreedState` | pins current toggle behavior (not strictly idempotent) | seed article | none | at least one row flips to `endorseState=0` after toggle |
+| ART-004 | HTTP/DB | `POST /wx/article/custom/updateFollowState` | same for follow | seed article | none | record updated |
+
+---
+
+## 14) Wages + Withdraw (WxClerkWagesController + WxWithdrawController)
+
+| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
+|---:|---|---|---|---|---|---|
+| WAGE-001 | HTTP/DB | `GET /wx/wages/clerk/queryUnsettledWages` | sums orders correctly | seed settlement_state=0 orders | none | totals pinned |
+| WAGE-002 | HTTP | `GET /wx/wages/clerk/queryCurrentPeriodWages` | missing wages row returns constructed zeros | no wages row | none | response has zeros and dates set |
+| WAGE-003 | HTTP | `POST /wx/wages/clerk/queryHistoricalWages` | current hard-coded page meta pinned | seed some rows | none | total=5,size=10,pages=1 pinned |
+| WD-001 | HTTP | `GET /wx/withdraw/balance` | returns available/pending/nextUnlock | seed earnings lines | none | values pinned |
+| WD-002 | HTTP | `GET /wx/withdraw/earnings` | time parsing supports multiple formats | seed earnings | none | filter correctness pinned |
+| WD-003 | HTTP/DB | `POST /wx/withdraw/requests` | amount<=0 error | none | none | message pinned |
+| WD-004 | HTTP/DB | create request | creates withdrawal request and reserves lines | seed available lines | none | statuses pinned |
+| WD-005 | HTTP | request logs | non-owner forbidden | seed request other clerk | none | `无权查看` |
+| PAYEE-001 | HTTP | `GET /wx/withdraw/payee` | no profile returns null data (current behavior) | none | none | response pinned |
+| PAYEE-002 | HTTP/DB | `POST /wx/withdraw/payee` | missing qrCodeUrl -> error | none | none | message pinned |
+| PAYEE-003 | HTTP/DB | upsert defaulting | defaults channel/displayName | seed clerk | none | stored defaults pinned |
+| PAYEE-004 | HTTP/DB | confirm requires qrCodeUrl | no profile | none | error message pinned |
+
+---
+
+## Coverage Checklist (for “done”)
+
+- Every `@RequestMapping("/wx...")` route has at least:
+ - 1 happy-path integration test
+ - 1 auth/tenant gating test (where applicable)
+ - 1 validation failure test (where applicable)
+ - 1 DB side-effect snapshot assertion for routes that mutate state
+- WeChat Pay callback has:
+ - replay/idempotency tests
+ - malformed XML test
+ - missing attach test
+- Media pipeline has:
+ - type detection + size/duration limits pinned
+ - DB uniqueness behavior pinned
+
+## Test Case ID Naming Convention (important)
+
+- In this doc, case IDs are written like `OAUTH-007`.
+- In Java test method names, we normalize them as `OAUTH_007` because `-` is not valid in Java identifiers.
+
+## Implemented Coverage (current)
+
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxOauthControllerApiTest.java`: `OAUTH-001..008`, `OAUTH-011`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxOauthServiceTest.java`: `OAUTH-009`, `OAUTH-010`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxPayControllerApiTest.java`: `PAY-001..011`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxAuthAspectApiTest.java`: `AOP-001..005`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxTokenServiceTest.java`: `TOK-001..002`
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerEndpointsApiTest.java`: `MED-005..008` (note: `MED-006` currently keeps `asset.deleted=0`)
+- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerAudioUploadApiTest.java`: covers audio upload behavior (see `MED-*` section for alignment)
diff --git a/play-admin/pom.xml b/play-admin/pom.xml
index 60180cd..1d13670 100644
--- a/play-admin/pom.xml
+++ b/play-admin/pom.xml
@@ -163,6 +163,11 @@
mockito-junit-jupiter
test
+
+ org.mockito
+ mockito-inline
+ test
+
org.springframework
spring-test
diff --git a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java
index 627df51..430e166 100644
--- a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java
+++ b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java
@@ -5,6 +5,9 @@ import com.starry.admin.modules.clerk.mapper.PlayClerkUserInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.module.enums.ClerkRoleStatus;
+import com.starry.admin.modules.clerk.module.enums.ListingStatus;
+import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
@@ -70,6 +73,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
public static final String DEFAULT_COMMODITY_ID = "svc-basic";
public static final String DEFAULT_CLERK_COMMODITY_ID = "clerk-svc-basic";
public static final String DEFAULT_CUSTOMER_ID = "customer-apitest";
+ public static final String DEFAULT_CUSTOMER_OPEN_ID = "openid-customer-apitest";
public static final String DEFAULT_GIFT_ID = "gift-basic";
public static final String DEFAULT_GIFT_NAME = "API测试礼物";
public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00");
@@ -190,6 +194,28 @@ public class ApiTestDataSeeder implements CommandLineRunner {
private void seedTenant() {
SysTenantEntity tenant = tenantService.getById(DEFAULT_TENANT_ID);
if (tenant != null) {
+ boolean changed = false;
+ if (tenant.getAppId() == null || tenant.getAppId().isEmpty()) {
+ tenant.setAppId("wx-apitest-appid");
+ changed = true;
+ }
+ if (tenant.getSecret() == null || tenant.getSecret().isEmpty()) {
+ tenant.setSecret("wx-apitest-secret");
+ changed = true;
+ }
+ if (tenant.getMchId() == null || tenant.getMchId().isEmpty()) {
+ tenant.setMchId("wx-apitest-mchid");
+ changed = true;
+ }
+ if (tenant.getMchKey() == null || tenant.getMchKey().isEmpty()) {
+ tenant.setMchKey("wx-apitest-mchkey");
+ changed = true;
+ }
+ if (changed) {
+ tenantService.updateById(tenant);
+ log.info("API test tenant {} already exists, wechat config refreshed", DEFAULT_TENANT_ID);
+ return;
+ }
log.info("API test tenant {} already exists", DEFAULT_TENANT_ID);
return;
}
@@ -201,6 +227,10 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setTenantStatus("0");
entity.setTenantCode("apitest");
entity.setTenantKey(DEFAULT_TENANT_KEY);
+ entity.setAppId("wx-apitest-appid");
+ entity.setSecret("wx-apitest-secret");
+ entity.setMchId("wx-apitest-mchid");
+ entity.setMchKey("wx-apitest-mchkey");
entity.setPackageId(DEFAULT_PACKAGE_ID);
entity.setTenantTime(new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000));
entity.setUserName(DEFAULT_ADMIN_USERNAME);
@@ -251,8 +281,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setGroupName("测试小组");
entity.setLeaderName("API Admin");
entity.setAddTime(LocalDateTime.now());
- personnelGroupInfoService.save(entity);
- log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
+ try {
+ personnelGroupInfoService.save(entity);
+ log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
+ } catch (DuplicateKeyException duplicateKeyException) {
+ log.info("API test personnel group {} already inserted by another test context", DEFAULT_GROUP_ID);
+ }
}
private void seedClerkLevel() {
@@ -274,8 +308,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setFirstRewardRatio(40);
entity.setNotFirstRewardRatio(35);
entity.setOrderNumber(1L);
- clerkLevelInfoService.save(entity);
- log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
+ try {
+ clerkLevelInfoService.save(entity);
+ log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
+ } catch (DuplicateKeyException duplicateKeyException) {
+ log.info("API test clerk level {} already inserted by another test context", DEFAULT_CLERK_LEVEL_ID);
+ }
}
private PlayCommodityInfoEntity seedCommodityHierarchy() {
@@ -406,8 +444,18 @@ public class ApiTestDataSeeder implements CommandLineRunner {
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID);
String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID);
if (clerk != null) {
- clerkUserInfoService.updateTokenById(DEFAULT_CLERK_ID, clerkToken);
- log.info("API test clerk {} already exists", DEFAULT_CLERK_ID);
+ clerkUserInfoService.update(Wrappers.lambdaUpdate()
+ .eq(PlayClerkUserInfoEntity::getId, DEFAULT_CLERK_ID)
+ .set(PlayClerkUserInfoEntity::getDeleted, Boolean.FALSE)
+ .set(PlayClerkUserInfoEntity::getOnboardingState, OnboardingStatus.ACTIVE.getCode())
+ .set(PlayClerkUserInfoEntity::getListingState, ListingStatus.LISTED.getCode())
+ .set(PlayClerkUserInfoEntity::getDisplayState, "1")
+ .set(PlayClerkUserInfoEntity::getRandomOrderState, "1")
+ .set(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode())
+ .set(PlayClerkUserInfoEntity::getOnlineState, "1")
+ .set(PlayClerkUserInfoEntity::getAvatar, "https://example.com/avatar.png")
+ .set(PlayClerkUserInfoEntity::getToken, clerkToken));
+ log.info("API test clerk {} already exists, state refreshed", DEFAULT_CLERK_ID);
return;
}
PlayClerkUserInfoEntity existing = clerkUserInfoMapper.selectByIdIncludingDeleted(DEFAULT_CLERK_ID);
@@ -561,7 +609,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(DEFAULT_CUSTOMER_ID);
entity.setTenantId(DEFAULT_TENANT_ID);
- entity.setOpenid("openid-customer-apitest");
+ entity.setOpenid(DEFAULT_CUSTOMER_OPEN_ID);
entity.setUnionid("unionid-customer-apitest");
entity.setNickname("测试顾客");
entity.setSex(1);
diff --git a/play-admin/src/test/java/com/starry/admin/api/MockWxMpServiceConfig.java b/play-admin/src/test/java/com/starry/admin/api/MockWxMpServiceConfig.java
index 903fc5b..fe582bf 100644
--- a/play-admin/src/test/java/com/starry/admin/api/MockWxMpServiceConfig.java
+++ b/play-admin/src/test/java/com/starry/admin/api/MockWxMpServiceConfig.java
@@ -24,7 +24,7 @@ public class MockWxMpServiceConfig {
WxMpService service = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
WxMpTemplateMsgService templateMsgService = mock(WxMpTemplateMsgService.class);
when(service.getTemplateMsgService()).thenReturn(templateMsgService);
- when(service.switchoverTo(Mockito.anyString())).thenReturn(service);
+ when(service.switchoverTo(Mockito.nullable(String.class))).thenReturn(service);
when(service.switchover(Mockito.anyString())).thenReturn(true);
return service;
}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxArticleControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxArticleControllerApiTest.java
new file mode 100644
index 0000000..660f63b
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxArticleControllerApiTest.java
@@ -0,0 +1,264 @@
+package com.starry.admin.api;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkArticleInfoEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayCustomArticleInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkArticleInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.clerk.service.IPlayCustomArticleInfoService;
+import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.utils.IdUtils;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+
+class WxArticleControllerApiTest extends AbstractApiTest {
+
+ @Autowired
+ private IPlayClerkArticleInfoService clerkArticleInfoService;
+
+ @Autowired
+ private IPlayCustomArticleInfoService customArticleInfoService;
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private IPlayCustomUserInfoService customUserInfoService;
+
+ private final java.util.List createdArticleIds = new java.util.ArrayList<>();
+ private final java.util.List createdCustomLinkIds = new java.util.ArrayList<>();
+ private String clerkToken;
+ private String customerToken;
+
+ @BeforeEach
+ void setUp() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ // Article create() blocks when there is any unaudited record for the clerk; keep tests deterministic.
+ clerkArticleInfoService.lambdaUpdate()
+ .eq(PlayClerkArticleInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .eq(PlayClerkArticleInfoEntity::getReviewState, "0")
+ .remove();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ if (!createdCustomLinkIds.isEmpty()) {
+ customArticleInfoService.removeByIds(createdCustomLinkIds);
+ createdCustomLinkIds.clear();
+ }
+ if (!createdArticleIds.isEmpty()) {
+ clerkArticleInfoService.removeByIds(createdArticleIds);
+ createdArticleIds.clear();
+ }
+ }
+
+ @Test
+ void clerkAddCreatesArticleWithSessionClerkId__covers_ART_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String marker = "apitest-article-" + IdUtils.getUuid();
+
+ String payload = "{" +
+ "\"articleCon\":\"" + marker + "\"," +
+ "\"annexType\":\"0\"," +
+ "\"annexCon\":[\"https://example.com/a.png\"]" +
+ "}";
+
+ mockMvc.perform(post("/wx/article/clerk/add")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.message").value("操作成功"));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayClerkArticleInfoEntity created = clerkArticleInfoService.lambdaQuery()
+ .eq(PlayClerkArticleInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .eq(PlayClerkArticleInfoEntity::getArticleCon, marker)
+ .last("limit 1")
+ .one();
+ if (created == null) {
+ throw new AssertionError("Expected article row to be created");
+ }
+ createdArticleIds.add(created.getId());
+ if (!ApiTestDataSeeder.DEFAULT_CLERK_ID.equals(created.getClerkId())) {
+ throw new AssertionError("Expected clerkId to be set from session");
+ }
+ }
+
+ @Test
+ void clerkDeleteByIdRemovesArticleAndCustomLinks__covers_ART_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String articleId = insertApprovedArticleForClerk();
+
+ PlayCustomArticleInfoEntity link = new PlayCustomArticleInfoEntity();
+ link.setId("apitest-link-" + IdUtils.getUuid());
+ link.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ link.setArticleId(articleId);
+ link.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ link.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ link.setEndorseType("1");
+ link.setEndorseState("1");
+ link.setEndorseTime(LocalDateTime.now());
+ customArticleInfoService.save(link);
+ createdCustomLinkIds.add(link.getId());
+
+ mockMvc.perform(get("/wx/article/clerk/deleteById")
+ .param("id", articleId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.message").value("操作成功"));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ if (clerkArticleInfoService.getById(articleId) != null) {
+ throw new AssertionError("Expected article to be deleted");
+ }
+ long remainingLinks = customArticleInfoService.lambdaQuery()
+ .eq(PlayCustomArticleInfoEntity::getArticleId, articleId)
+ .count();
+ if (remainingLinks != 0) {
+ throw new AssertionError("Expected custom article links to be deleted");
+ }
+ }
+
+ @Test
+ void customUpdateGreedStateUpsertsAndUpdatesEndorseTime__covers_ART_003() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String articleId = insertApprovedArticleForClerk();
+
+ LocalDateTime before = LocalDateTime.now().minusSeconds(1);
+
+ mockMvc.perform(post("/wx/article/custom/updateGreedState")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"" + articleId + "\",\"greedState\":\"1\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ java.util.List likedRows = customArticleInfoService.lambdaQuery()
+ .eq(PlayCustomArticleInfoEntity::getArticleId, articleId)
+ .eq(PlayCustomArticleInfoEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayCustomArticleInfoEntity::getEndorseType, "1")
+ .list();
+ if (likedRows == null || likedRows.isEmpty()) {
+ throw new AssertionError("Expected custom article greed record(s) to be created");
+ }
+ for (PlayCustomArticleInfoEntity item : likedRows) {
+ createdCustomLinkIds.add(item.getId());
+ }
+ boolean anyLiked = likedRows.stream().anyMatch(item -> "1".equals(item.getEndorseState()));
+ if (!anyLiked) {
+ throw new AssertionError("Expected at least one greed record endorseState=1");
+ }
+ boolean anyRecent = likedRows.stream()
+ .anyMatch(item -> item.getEndorseTime() != null && !item.getEndorseTime().isBefore(before));
+ if (!anyRecent) {
+ throw new AssertionError("Expected endorseTime to be set");
+ }
+ mockMvc.perform(post("/wx/article/custom/updateGreedState")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"" + articleId + "\",\"greedState\":\"0\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ java.util.List afterUnlikeRows = customArticleInfoService.lambdaQuery()
+ .eq(PlayCustomArticleInfoEntity::getArticleId, articleId)
+ .eq(PlayCustomArticleInfoEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayCustomArticleInfoEntity::getEndorseType, "1")
+ .list();
+ if (afterUnlikeRows == null || afterUnlikeRows.isEmpty()) {
+ throw new AssertionError("Expected custom article greed record(s) to exist after update");
+ }
+ for (PlayCustomArticleInfoEntity item : afterUnlikeRows) {
+ createdCustomLinkIds.add(item.getId());
+ }
+ boolean anyUnliked = afterUnlikeRows.stream().anyMatch(item -> "0".equals(item.getEndorseState()));
+ if (!anyUnliked) {
+ throw new AssertionError("Expected at least one greed record endorseState=0 after update");
+ }
+ }
+
+ @Test
+ void customUpdateFollowStateUpsertsAndUpdatesEndorseTime__covers_ART_004() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String articleId = insertApprovedArticleForClerk();
+
+ mockMvc.perform(post("/wx/article/custom/updateFollowState")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"" + articleId + "\",\"followState\":\"1\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayCustomArticleInfoEntity row = customArticleInfoService.selectByArticleId(
+ articleId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, "0");
+ if (row == null) {
+ throw new AssertionError("Expected follow record to be created");
+ }
+ createdCustomLinkIds.add(row.getId());
+ if (!"0".equals(row.getEndorseType())) {
+ throw new AssertionError("Expected endorseType=0 for follow");
+ }
+ if (!"1".equals(row.getEndorseState())) {
+ throw new AssertionError("Expected endorseState=1");
+ }
+ if (row.getEndorseTime() == null) {
+ throw new AssertionError("Expected endorseTime to be set");
+ }
+ }
+
+ private String insertApprovedArticleForClerk() {
+ PlayClerkArticleInfoEntity entity = new PlayClerkArticleInfoEntity();
+ String id = "apitest-clerk-article-" + IdUtils.getUuid();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ entity.setArticleCon("seed");
+ entity.setAnnexType("0");
+ entity.setAnnexCon(Collections.singletonList("https://example.com/seed.png"));
+ entity.setReviewState("1");
+ entity.setReleaseTime(LocalDateTime.now());
+ entity.setDeleted(Boolean.FALSE);
+ clerkArticleInfoService.save(entity);
+ createdArticleIds.add(id);
+ return id;
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxAuthAspectApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxAuthAspectApiTest.java
new file mode 100644
index 0000000..2b4984d
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxAuthAspectApiTest.java
@@ -0,0 +1,150 @@
+package com.starry.admin.api;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
+import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.bean.result.WxMpUser;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+class WxAuthAspectApiTest extends AbstractApiTest {
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayCustomUserInfoService customUserInfoService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private WxMpService wxMpService;
+
+ @Test
+ void customUserLoginAspectRejectsMissingTokenHeader__covers_AOP_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(get("/wx/oauth2/checkSubscribe")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401))
+ .andExpect(jsonPath("$.message").value("token为空"));
+ }
+
+ @Test
+ void clerkUserLoginAspectRejectsMissingTokenHeader__covers_AOP_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(get("/wx/oauth2/clerk/logout")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401))
+ .andExpect(jsonPath("$.message").value("token为空"));
+ }
+
+ @Test
+ void customUserLoginAspectRejectsInvalidTokenFormat__covers_AOP_003() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(get("/wx/oauth2/checkSubscribe")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + "not-a-jwt"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401))
+ .andExpect(jsonPath("$.message").value("获取用户信息异常"));
+ }
+
+ @Test
+ void customUserLoginAspectRejectsTokenMismatchAgainstDatabase__covers_AOP_004() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ String userId = ApiTestDataSeeder.DEFAULT_CUSTOMER_ID;
+ String token = wxTokenService.createWxUserToken(userId);
+ customUserInfoService.updateTokenById(userId, "some-other-token");
+
+ mockMvc.perform(get("/wx/oauth2/checkSubscribe")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401))
+ .andExpect(jsonPath("$.message").value("token异常"));
+ }
+
+ @Test
+ void clerkUserLoginAspectRejectsOffboardedClerkAndInvalidatesSession__covers_AOP_005() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ String clerkId = "clerk-aop-offboarded";
+ String openId = "openid-clerk-aop-offboarded";
+ String token = wxTokenService.createWxUserToken(clerkId);
+
+ PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
+ if (existing == null) {
+ PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
+ entity.setId(clerkId);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setOpenid(openId);
+ entity.setNickname("API Test Offboarded Clerk");
+ entity.setAvatar("https://example.com/avatar.png");
+ entity.setSysUserId("");
+ entity.setOnboardingState("0");
+ entity.setListingState("1");
+ entity.setClerkState("1");
+ entity.setOnlineState("1");
+ entity.setToken(token);
+ clerkUserInfoService.save(entity);
+ } else {
+ PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
+ patch.setId(clerkId);
+ patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ patch.setOpenid(openId);
+ patch.setAvatar("https://example.com/avatar.png");
+ patch.setSysUserId("");
+ patch.setOnboardingState("0");
+ patch.setListingState("1");
+ patch.setClerkState("1");
+ patch.setOnlineState("1");
+ patch.setDeleted(Boolean.FALSE);
+ patch.setToken(token);
+ clerkUserInfoService.updateById(patch);
+ }
+
+ WxMpUser wxMpUser = new WxMpUser();
+ wxMpUser.setSubscribe(true);
+ when(wxMpService.getUserService().userInfo(anyString())).thenReturn(wxMpUser);
+
+ mockMvc.perform(get("/wx/oauth2/clerk/logout")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401))
+ .andExpect(jsonPath("$.message").value("你已离职,需要复职请联系店铺管理员"));
+
+ PlayClerkUserInfoEntity after = clerkUserInfoService.getById(clerkId);
+ if (after == null) {
+ throw new AssertionError("Expected clerk to exist");
+ }
+ if (!"empty".equals(after.getToken())) {
+ throw new AssertionError("Expected clerk token to be invalidated to 'empty'");
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxControllerApiTest.java
new file mode 100644
index 0000000..9c8e496
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxControllerApiTest.java
@@ -0,0 +1,293 @@
+package com.starry.admin.api;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
+import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
+import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
+import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
+import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
+import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
+import com.starry.admin.modules.shop.module.constant.GiftHistory;
+import com.starry.admin.modules.shop.module.constant.GiftState;
+import com.starry.admin.modules.shop.module.constant.GiftType;
+import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
+import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.context.CustomSecurityContextHolder;
+import com.starry.common.utils.IdUtils;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Objects;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxBlindBoxControllerApiTest extends WxCustomOrderApiTestSupport {
+
+ @Autowired
+ private BlindBoxConfigService blindBoxConfigService;
+
+ @Autowired
+ private BlindBoxPoolMapper blindBoxPoolMapper;
+
+ @Autowired
+ private IPlayGiftInfoService giftInfoService;
+
+ @Autowired
+ private BlindBoxRewardMapper blindBoxRewardMapper;
+
+ private String customerToken;
+
+ private final java.util.List configIdsToCleanup = new java.util.ArrayList<>();
+ private final java.util.List poolIdsToCleanup = new java.util.ArrayList<>();
+ private final java.util.List giftIdsToCleanup = new java.util.ArrayList<>();
+ private final java.util.List rewardIdsToCleanup = new java.util.ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ ensureTenantContext();
+ resetCustomerBalance();
+ customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+ }
+
+ @AfterEach
+ void tearDown() {
+ ensureTenantContext();
+ for (String id : rewardIdsToCleanup) {
+ blindBoxRewardMapper.deleteById(id);
+ }
+ for (Long id : poolIdsToCleanup) {
+ blindBoxPoolMapper.deleteById(id);
+ }
+ for (String id : configIdsToCleanup) {
+ blindBoxConfigService.removeById(id);
+ }
+ for (String id : giftIdsToCleanup) {
+ giftInfoService.removeById(id);
+ }
+ CustomSecurityContextHolder.remove();
+ }
+
+ @Test
+ void listConfigsRejectsMissingToken__covers_BB_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(get("/wx/blind-box/config/list")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401))
+ .andExpect(jsonPath("$.message").value("token为空"));
+ }
+
+ @Test
+ void listConfigsReturnsOnlyActiveConfigsForTenant__covers_BB_002() throws Exception {
+ ensureTenantContext();
+ BlindBoxConfigEntity active = new BlindBoxConfigEntity();
+ active.setId("bb-active-" + IdUtils.getUuid().substring(0, 8));
+ active.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ active.setName("API盲盒-上架");
+ active.setPrice(new BigDecimal("9.99"));
+ active.setStatus(1);
+ blindBoxConfigService.save(active);
+ configIdsToCleanup.add(active.getId());
+
+ BlindBoxConfigEntity inactive = new BlindBoxConfigEntity();
+ inactive.setId("bb-off-" + IdUtils.getUuid().substring(0, 8));
+ inactive.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ inactive.setName("API盲盒-下架");
+ inactive.setPrice(new BigDecimal("9.99"));
+ inactive.setStatus(0);
+ blindBoxConfigService.save(inactive);
+ configIdsToCleanup.add(inactive.getId());
+
+ mockMvc.perform(get("/wx/blind-box/config/list")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItem(active.getId())))
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(inactive.getId()))));
+ }
+
+ @Test
+ void purchaseRejectsTenantMismatch__covers_BB_003() throws Exception {
+ ensureTenantContext();
+ BlindBoxConfigEntity config = new BlindBoxConfigEntity();
+ config.setId("bb-other-" + IdUtils.getUuid().substring(0, 8));
+ config.setTenantId("other-tenant");
+ config.setName("API盲盒-其他租户");
+ config.setPrice(new BigDecimal("9.99"));
+ config.setStatus(1);
+ blindBoxConfigService.save(config);
+ configIdsToCleanup.add(config.getId());
+
+ String payload = objectMapper.createObjectNode()
+ .put("blindBoxId", config.getId())
+ .put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .put("weiChatCode", "apitest-customer-wx")
+ .toString();
+
+ mockMvc.perform(post("/wx/blind-box/order/purchase")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("盲盒不存在"));
+ }
+
+ @Test
+ void dispatchRejectsWhenRewardNotFound__covers_BB_005() throws Exception {
+ ensureTenantContext();
+ String payload = objectMapper.createObjectNode()
+ .put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .toString();
+
+ mockMvc.perform(post("/wx/blind-box/reward/not-found-" + IdUtils.getUuid().substring(0, 6) + "/dispatch")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("盲盒奖励不存在"));
+ }
+
+ @Test
+ void listRewardsFiltersByStatus__covers_BB_007() throws Exception {
+ ensureTenantContext();
+ String configId = "bb-list-" + IdUtils.getUuid().substring(0, 6);
+ String giftId = "bb-gift-" + IdUtils.getUuid().substring(0, 6);
+ Long poolId = null;
+
+ BlindBoxConfigEntity config = new BlindBoxConfigEntity();
+ config.setId(configId);
+ config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ config.setName("API盲盒-列表");
+ config.setPrice(new BigDecimal("19.90"));
+ config.setStatus(1);
+ blindBoxConfigService.save(config);
+ configIdsToCleanup.add(configId);
+
+ PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
+ gift.setId(giftId);
+ gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ gift.setName("盲盒礼物-列表");
+ gift.setHistory(GiftHistory.CURRENT.getCode());
+ gift.setState(GiftState.ACTIVE.getCode());
+ gift.setType(GiftType.NORMAL.getCode());
+ gift.setUrl("https://example.com/apitest/blindbox-list.png");
+ gift.setPrice(new BigDecimal("9.99"));
+ giftInfoService.save(gift);
+ giftIdsToCleanup.add(giftId);
+
+ BlindBoxPoolEntity entry = new BlindBoxPoolEntity();
+ entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entry.setBlindBoxId(configId);
+ entry.setRewardGiftId(giftId);
+ entry.setRewardPrice(gift.getPrice());
+ entry.setWeight(100);
+ entry.setRemainingStock(10);
+ entry.setStatus(1);
+ entry.setValidFrom(LocalDateTime.now().minusDays(1));
+ entry.setValidTo(LocalDateTime.now().plusDays(1));
+ blindBoxPoolMapper.insert(entry);
+ poolId = entry.getId();
+ poolIdsToCleanup.add(poolId);
+
+ String purchasePayload = objectMapper.createObjectNode()
+ .put("blindBoxId", configId)
+ .put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .put("weiChatCode", "apitest-customer-wx")
+ .toString();
+
+ MvcResult firstPurchase = mockMvc.perform(post("/wx/blind-box/order/purchase")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(purchasePayload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode rewardNode = objectMapper.readTree(firstPurchase.getResponse().getContentAsString())
+ .path("data").path("reward");
+ String usedRewardId = rewardNode.path("rewardId").asText();
+ Assertions.assertThat(usedRewardId).isNotBlank();
+ rewardIdsToCleanup.add(usedRewardId);
+
+ mockMvc.perform(post("/wx/blind-box/reward/" + usedRewardId + "/dispatch")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.createObjectNode()
+ .put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.status").value("USED"));
+
+ MvcResult secondPurchase = mockMvc.perform(post("/wx/blind-box/order/purchase")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(purchasePayload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ String unusedRewardId = objectMapper.readTree(secondPurchase.getResponse().getContentAsString())
+ .path("data").path("reward").path("rewardId").asText();
+ Assertions.assertThat(unusedRewardId).isNotBlank();
+ rewardIdsToCleanup.add(unusedRewardId);
+
+ mockMvc.perform(get("/wx/blind-box/reward/list")
+ .param("status", "UNUSED")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItem(unusedRewardId)))
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(usedRewardId))));
+
+ mockMvc.perform(get("/wx/blind-box/reward/list")
+ .param("status", "USED")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItem(usedRewardId)))
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(unusedRewardId))));
+
+ BlindBoxRewardEntity storedUsed = blindBoxRewardMapper.selectById(usedRewardId);
+ Assertions.assertThat(storedUsed).isNotNull();
+ Assertions.assertThat(storedUsed.getStatus()).isEqualTo("USED");
+ Assertions.assertThat(storedUsed.getUsedClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+
+ if (Objects.nonNull(poolId)) {
+ blindBoxPoolMapper.deleteById(poolId);
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java
index 719d6eb..4652ef2 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java
@@ -49,7 +49,7 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
private IPlayClerkGiftInfoService clerkGiftInfoService;
@Test
- void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception {
+ void purchaseRejectsWhenBalanceInsufficient__covers_BB_008() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String configId = "blind-" + IdUtils.getUuid();
try {
@@ -99,7 +99,7 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
}
@Test
- void blindBoxPurchaseAndDispatchSucceedWhenGiftInactive() throws Exception {
+ void purchaseCreatesCompletedOrderAndRewardAndDispatchMarksUsed__covers_BB_004__covers_BB_006() throws Exception {
String configId = "blind-inactive-" + IdUtils.getUuid().substring(0, 6);
String giftId = "gift-inactive-" + IdUtils.getUuid().substring(0, 6);
Long poolId = null;
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxClerkAlbumUpdateApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxClerkAlbumUpdateApiTest.java
index 0447d1d..f0e4155 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxClerkAlbumUpdateApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxClerkAlbumUpdateApiTest.java
@@ -24,6 +24,7 @@ import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo;
import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
+import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -210,7 +211,7 @@ class WxClerkAlbumUpdateApiTest extends AbstractApiTest {
}
@Test
- void updateAlbumRejectsEmptyAlbumPayload() throws Exception {
+ void updateAlbumRejectsEmptyAlbumPayload__covers_CLK_010() throws Exception {
ensureTenantContext();
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
String clerkToken = wxTokenService.createWxUserToken(clerkId);
@@ -228,18 +229,53 @@ class WxClerkAlbumUpdateApiTest extends AbstractApiTest {
.andExpect(status().isOk())
.andReturn();
- String body = result.getResponse().getContentAsString();
+ String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
com.fasterxml.jackson.databind.JsonNode root = new ObjectMapper().readTree(body);
assertThat(root.path("code").asInt())
.as("empty album should be rejected, response=%s", body)
.isEqualTo(500);
assertThat(root.path("message").asText())
- .as("error message for empty album should be present, response=%s", body)
- .isNotBlank();
+ .as("error message for empty album should be pinned, response=%s", body)
+ .isEqualTo("最少上传一张照片");
}
@Test
- void updateAlbumAllowsMixedLegacyUrlsAndNewMediaIdsForReview() throws Exception {
+ void updateAlbumRejectsInvalidNewMediaOwnershipOrStatus__covers_CLK_011() throws Exception {
+ ensureTenantContext();
+ String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
+ String clerkToken = wxTokenService.createWxUserToken(clerkId);
+ clerkUserInfoService.updateTokenById(clerkId, clerkToken);
+
+ PlayMediaEntity invalid = new PlayMediaEntity();
+ invalid.setId("media-invalid-" + com.starry.common.utils.IdUtils.getUuid().substring(0, 8));
+ invalid.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ invalid.setOwnerType(com.starry.admin.modules.media.enums.MediaOwnerType.CLERK);
+ invalid.setOwnerId("other-clerk");
+ invalid.setKind("image");
+ invalid.setStatus(com.starry.admin.modules.media.enums.MediaStatus.READY.getCode());
+ invalid.setUrl("https://example.com/invalid.png");
+ mediaService.save(invalid);
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.putArray("album").add(invalid.getId());
+
+ MvcResult result = mockMvc.perform(post("/wx/clerk/user/updateAlbum")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
+ com.fasterxml.jackson.databind.JsonNode root = new ObjectMapper().readTree(body);
+ assertThat(root.path("code").asInt()).isEqualTo(500);
+ assertThat(root.path("message").asText()).isEqualTo("存在无效的照片/视频,请刷新后重试");
+ }
+
+ @Test
+ void updateAlbumAllowsMixedLegacyUrlsAndNewMediaIdsForReview__covers_CLK_012() throws Exception {
ensureTenantContext();
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
String clerkToken = wxTokenService.createWxUserToken(clerkId);
@@ -286,6 +322,17 @@ class WxClerkAlbumUpdateApiTest extends AbstractApiTest {
assertThat(reviewCountAfter)
.as("mixed legacy URLs and new media ids should create exactly one new review record")
.isEqualTo(reviewCountBefore + 1);
+
+ ensureTenantContext();
+ com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity review =
+ dataReviewInfoService.lambdaQuery()
+ .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId)
+ .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2")
+ .orderByDesc(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+ assertThat(review).isNotNull();
+ assertThat(review.getDataContent()).contains(legacyUrl1, legacyUrl2, media.getId());
}
private PlayMediaEntity seedMedia(String clerkId) {
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxClerkControllerUserApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxClerkControllerUserApiTest.java
new file mode 100644
index 0000000..982bf9f
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxClerkControllerUserApiTest.java
@@ -0,0 +1,488 @@
+package com.starry.admin.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import cn.hutool.crypto.SecureUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.common.exception.ServiceException;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserReviewInfoService;
+import com.starry.admin.modules.order.module.constant.OrderConstant;
+import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity;
+import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
+import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
+import com.starry.admin.modules.order.service.IPlayOrderInfoService;
+import com.starry.admin.modules.weichat.service.WxCustomMpService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.context.CustomSecurityContextHolder;
+import com.starry.common.redis.RedisCache;
+import com.starry.common.utils.IdUtils;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxClerkControllerUserApiTest extends AbstractApiTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private RedisCache redisCache;
+
+ @Autowired
+ private IPlayClerkDataReviewInfoService dataReviewInfoService;
+
+ @Autowired
+ private IPlayClerkUserReviewInfoService clerkUserReviewInfoService;
+
+ @Autowired
+ private IPlayOrderEvaluateInfoService orderEvaluateInfoService;
+
+ @Autowired
+ private IPlayOrderInfoService orderInfoService;
+
+ @MockBean
+ private WxCustomMpService wxCustomMpService;
+
+ @AfterEach
+ void tearDown() {
+ CustomSecurityContextHolder.remove();
+ }
+
+ @Test
+ void queryPerformanceInfoIsStableForSameInput__covers_CLK_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+
+ String payload = "{\"startTime\":\"2026-01-01\",\"endTime\":\"2026-01-02\"}";
+ MvcResult first = mockMvc.perform(post("/wx/clerk/user/queryPerformanceInfo")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ MvcResult second = mockMvc.perform(post("/wx/clerk/user/queryPerformanceInfo")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ assertThat(second.getResponse().getContentAsString(StandardCharsets.UTF_8))
+ .isEqualTo(first.getResponse().getContentAsString(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void queryLevelInfoReturnsPinnedRankingList__covers_CLK_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+
+ mockMvc.perform(get("/wx/clerk/user/queryLevelInfo")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.levelAndRanking", hasSize(6)))
+ .andExpect(jsonPath("$.data.levelAndRanking[0].levelName").value("女神"))
+ .andExpect(jsonPath("$.data.levelAndRanking[5].levelName").value("普通"));
+ }
+
+ @Test
+ void sendCodeWritesRedisKeyAndReturnsCode__covers_CLK_003() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+
+ String areaCode = "+86";
+ String phone = "13900001234";
+ String payload = "{\"areaCode\":\"" + areaCode + "\",\"phone\":\"" + phone + "\"}";
+
+ MvcResult result = mockMvc.perform(post("/wx/clerk/user/sendCode")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8));
+ String code = root.path("data").asText();
+ assertThat(code).hasSize(4);
+
+ String codeKey = "login_codes:" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "_" + SecureUtil.md5(areaCode + phone);
+ String stored = redisCache.getCacheObject(codeKey);
+ assertThat(stored).isEqualTo(code);
+
+ Long ttlSeconds = redisCache.redisTemplate.getExpire(codeKey, TimeUnit.SECONDS);
+ assertThat(ttlSeconds).isNotNull();
+ assertThat(ttlSeconds).isGreaterThan(0);
+ }
+
+ @Test
+ void bindCodeRejectsWrongCode__covers_CLK_004() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+
+ String areaCode = "+86";
+ String phone = "13900002222";
+ String codeKey = "login_codes:" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "_" + SecureUtil.md5(areaCode + phone);
+ redisCache.setCacheObject(codeKey, "1234", 5L, TimeUnit.MINUTES);
+
+ String payload = "{\"areaCode\":\"" + areaCode + "\",\"phone\":\"" + phone + "\",\"code\":\"0000\"}";
+ mockMvc.perform(post("/wx/clerk/user/bindCode")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("验证码错误"));
+
+ assertThat(redisCache.getCacheObject(codeKey)).isEqualTo("1234");
+ }
+
+ @Test
+ void bindCodeUpdatesPhoneAndClearsRedis__covers_CLK_005() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+
+ PlayClerkUserInfoEntity before = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ assertThat(before).isNotNull();
+ String originalPhone = before.getPhone();
+
+ String areaCode = "+86";
+ String phone = "13900003333";
+ String codeKey = "login_codes:" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "_" + SecureUtil.md5(areaCode + phone);
+ redisCache.setCacheObject(codeKey, "9999", 5L, TimeUnit.MINUTES);
+
+ try {
+ String payload = "{\"areaCode\":\"" + areaCode + "\",\"phone\":\"" + phone + "\",\"code\":\"9999\"}";
+ mockMvc.perform(post("/wx/clerk/user/bindCode")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayClerkUserInfoEntity after = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ assertThat(after.getPhone()).isEqualTo(phone);
+ assertThat(redisCache.getCacheObject(codeKey)).isNull();
+ } finally {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
+ patch.setId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ patch.setPhone(originalPhone);
+ clerkUserInfoService.updateById(patch);
+ }
+ }
+
+ @Test
+ void userAddRejectsWhenAlreadyClerk__covers_CLK_006() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ doNothing().when(wxCustomMpService).checkSubscribeThrowsExp(org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString());
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("nickname", "申请人");
+ payload.put("sex", "2");
+ payload.put("age", 18);
+ payload.put("weiChatCode", "wx-apply");
+ payload.put("province", "Guangdong");
+ payload.put("city", "Shenzhen");
+ payload.put("audio", "https://oss.example/audio.mp3");
+ payload.set("album", objectMapper.createArrayNode());
+
+ mockMvc.perform(post("/wx/clerk/user/add")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("当前用户已经是店员"));
+ }
+
+ @Test
+ void userAddRejectsWhenPendingReviewExists__covers_CLK_007() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ doNothing().when(wxCustomMpService).checkSubscribeThrowsExp(org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString());
+
+ String clerkId = "clerk-pending-" + IdUtils.getUuid().substring(0, 8);
+ ensureClerk(clerkId, "openid-" + clerkId, "0", "");
+ String clerkToken = ensureClerkToken(clerkId);
+
+ PlayClerkUserReviewInfoEntity review = new PlayClerkUserReviewInfoEntity();
+ review.setId("review-" + IdUtils.getUuid().substring(0, 8));
+ review.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ review.setClerkId(clerkId);
+ review.setReviewState("0");
+ review.setNickname("pending");
+ review.setSex("2");
+ review.setAge(18);
+ review.setProvince("GD");
+ review.setCity("SZ");
+ review.setWeiChatCode("wx");
+ review.setAudio("https://oss.example/audio.mp3");
+ review.setAddTime(LocalDateTime.now());
+ clerkUserReviewInfoService.save(review);
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("nickname", "申请人");
+ payload.put("sex", "2");
+ payload.put("age", 18);
+ payload.put("weiChatCode", "wx-apply");
+ payload.put("province", "Guangdong");
+ payload.put("city", "Shenzhen");
+ payload.put("audio", "https://oss.example/audio.mp3");
+ payload.set("album", objectMapper.createArrayNode());
+
+ mockMvc.perform(post("/wx/clerk/user/add")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("已有申请未审核"));
+ }
+
+ @Test
+ void userAddRequiresSubscribe__covers_CLK_008() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkId = "clerk-unsub-" + IdUtils.getUuid().substring(0, 8);
+ ensureClerk(clerkId, "openid-" + clerkId, "0", "");
+ String clerkToken = ensureClerkToken(clerkId);
+
+ doThrow(new ServiceException("请先关注公众号然后再来使用系统~"))
+ .when(wxCustomMpService)
+ .checkSubscribeThrowsExp(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("nickname", "申请人");
+ payload.put("sex", "2");
+ payload.put("age", 18);
+ payload.put("weiChatCode", "wx-apply");
+ payload.put("province", "Guangdong");
+ payload.put("city", "Shenzhen");
+ payload.put("audio", "https://oss.example/audio.mp3");
+ payload.set("album", objectMapper.createArrayNode());
+
+ mockMvc.perform(post("/wx/clerk/user/add")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请先关注公众号然后再来使用系统~"));
+ }
+
+ @Test
+ void updateNicknameCreatesDataReviewRow__covers_CLK_009() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+
+ String nickname = "nick-" + IdUtils.getUuid().substring(0, 6);
+ String payload = "{\"nickname\":\"" + nickname + "\"}";
+
+ mockMvc.perform(post("/wx/clerk/user/updateNickname")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayClerkDataReviewInfoEntity latest = dataReviewInfoService.lambdaQuery()
+ .eq(PlayClerkDataReviewInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .eq(PlayClerkDataReviewInfoEntity::getDataType, "0")
+ .orderByDesc(PlayClerkDataReviewInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+ assertThat(latest).isNotNull();
+ assertThat(latest.getReviewState()).isEqualTo("0");
+ assertThat(latest.getDataContent()).isEqualTo(List.of(nickname));
+ }
+
+ @Test
+ void queryEvaluateByPageForcesHiddenVisible__covers_CLK_020() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ String visibleOrderId = "ordevl-v-" + IdUtils.getUuid().substring(0, 8);
+ String hiddenOrderId = "ordevl-h-" + IdUtils.getUuid().substring(0, 8);
+ String visibleId = "eval-visible-" + IdUtils.getUuid().substring(0, 8);
+ String hiddenId = "eval-hidden-" + IdUtils.getUuid().substring(0, 8);
+ String keyword = "kw-" + IdUtils.getUuid().substring(0, 6);
+ try {
+ PlayOrderInfoEntity visibleOrder = new PlayOrderInfoEntity();
+ visibleOrder.setId(visibleOrderId);
+ visibleOrder.setOrderNo("EVAL-" + IdUtils.getUuid().substring(0, 4));
+ visibleOrder.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ visibleOrder.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode());
+ visibleOrder.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
+ visibleOrder.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
+ visibleOrder.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ visibleOrder.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ visibleOrder.setOrderMoney(new java.math.BigDecimal("99.00"));
+ visibleOrder.setFinalAmount(new java.math.BigDecimal("99.00"));
+ visibleOrder.setPayMethod("0");
+ visibleOrder.setPayState("1");
+ visibleOrder.setCommodityId("svc-basic");
+ visibleOrder.setCommodityType("1");
+ visibleOrder.setCommodityName("Weixin Order");
+ visibleOrder.setCommodityPrice(new java.math.BigDecimal("99.00"));
+ visibleOrder.setCommodityNumber("1");
+ visibleOrder.setServiceDuration("60min");
+ visibleOrder.setCreatedTime(new java.util.Date());
+ visibleOrder.setUpdatedTime(new java.util.Date());
+ orderInfoService.save(visibleOrder);
+
+ PlayOrderInfoEntity hiddenOrder = new PlayOrderInfoEntity();
+ hiddenOrder.setId(hiddenOrderId);
+ hiddenOrder.setOrderNo("EVAL-" + IdUtils.getUuid().substring(0, 4));
+ hiddenOrder.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ hiddenOrder.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode());
+ hiddenOrder.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
+ hiddenOrder.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
+ hiddenOrder.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ hiddenOrder.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ hiddenOrder.setOrderMoney(new java.math.BigDecimal("99.00"));
+ hiddenOrder.setFinalAmount(new java.math.BigDecimal("99.00"));
+ hiddenOrder.setPayMethod("0");
+ hiddenOrder.setPayState("1");
+ hiddenOrder.setCommodityId("svc-basic");
+ hiddenOrder.setCommodityType("1");
+ hiddenOrder.setCommodityName("Weixin Order");
+ hiddenOrder.setCommodityPrice(new java.math.BigDecimal("99.00"));
+ hiddenOrder.setCommodityNumber("1");
+ hiddenOrder.setServiceDuration("60min");
+ hiddenOrder.setCreatedTime(new java.util.Date());
+ hiddenOrder.setUpdatedTime(new java.util.Date());
+ orderInfoService.save(hiddenOrder);
+
+ PlayOrderEvaluateInfoEntity visible = new PlayOrderEvaluateInfoEntity();
+ visible.setId(visibleId);
+ visible.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ visible.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ visible.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ visible.setOrderId(visibleOrderId);
+ visible.setAnonymous("0");
+ visible.setEvaluateType("0");
+ visible.setEvaluateLevel(5);
+ visible.setEvaluateCon(keyword);
+ visible.setEvaluateTime(new java.util.Date());
+ visible.setHidden("0");
+ orderEvaluateInfoService.save(visible);
+
+ PlayOrderEvaluateInfoEntity hidden = new PlayOrderEvaluateInfoEntity();
+ hidden.setId(hiddenId);
+ hidden.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ hidden.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ hidden.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ hidden.setOrderId(hiddenOrderId);
+ hidden.setAnonymous("0");
+ hidden.setEvaluateType("0");
+ hidden.setEvaluateLevel(1);
+ hidden.setEvaluateCon(keyword);
+ hidden.setEvaluateTime(new java.util.Date());
+ hidden.setHidden("1");
+ orderEvaluateInfoService.save(hidden);
+
+ String payload = "{\"pageNum\":1,\"pageSize\":20,\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\","
+ + "\"hidden\":\"1\",\"evaluateCon\":\"" + keyword + "\"}";
+ MvcResult result = mockMvc.perform(post("/wx/clerk/user/queryEvaluateByPage")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode records = objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
+ .path("data");
+ assertThat(records)
+ .anyMatch(node -> visibleId.equals(node.path("id").asText()))
+ .noneMatch(node -> hiddenId.equals(node.path("id").asText()));
+ } finally {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ orderEvaluateInfoService.removeById(visibleId);
+ orderEvaluateInfoService.removeById(hiddenId);
+ orderInfoService.removeById(visibleOrderId);
+ orderInfoService.removeById(hiddenOrderId);
+ }
+ }
+
+ private String ensureClerkToken(String clerkId) {
+ String token = wxTokenService.createWxUserToken(clerkId);
+ clerkUserInfoService.updateTokenById(clerkId, token);
+ return token;
+ }
+
+ private void ensureClerk(String clerkId, String openid, String clerkState, String sysUserId) {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
+ patch.setId(clerkId);
+ patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ patch.setOpenid(openid);
+ patch.setNickname("API Clerk " + clerkId);
+ patch.setSysUserId(sysUserId);
+ patch.setClerkState(clerkState);
+ patch.setOnboardingState("1");
+ patch.setListingState("1");
+ patch.setOnlineState("1");
+ if (clerkUserInfoService.getById(clerkId) == null) {
+ clerkUserInfoService.save(patch);
+ } else {
+ patch.setDeleted(Boolean.FALSE);
+ clerkUserInfoService.updateById(patch);
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java
index 073f9a1..7b099cf 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java
@@ -1,6 +1,7 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
@@ -31,9 +32,11 @@ import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.enums.ClerkReviewState;
+import com.starry.common.utils.IdUtils;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.Comparator;
@@ -43,6 +46,7 @@ import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MvcResult;
@@ -73,7 +77,31 @@ class WxClerkMediaControllerApiTest extends AbstractApiTest {
private IOssFileService ossFileService;
@Test
- void clerkCanUploadImageMediaAndPersistUrl() throws Exception {
+ void uploadRejectsEmptyFile__covers_MED_001() throws Exception {
+ ensureTenantContext();
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ MockMultipartFile emptyFile = new MockMultipartFile(
+ "file",
+ "empty.png",
+ "image/png",
+ new byte[0]);
+
+ mockMvc.perform(multipart("/wx/clerk/media/upload")
+ .file(emptyFile)
+ .param("usage", "profile")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请选择要上传的文件"));
+ }
+
+ @Test
+ void clerkCanUploadImageMediaAndPersistUrl__covers_MED_002() throws Exception {
ensureTenantContext();
String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
@@ -119,6 +147,104 @@ class WxClerkMediaControllerApiTest extends AbstractApiTest {
assertThat(persisted.getUrl()).isEqualTo(ossUrl);
}
+ @Test
+ void uploadRejectsOversizedVideo__covers_MED_003() throws Exception {
+ ensureTenantContext();
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ byte[] oversized = new byte[(int) (30L * 1024 * 1024 + 1)];
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "big.mp4",
+ "video/mp4",
+ oversized);
+
+ mockMvc.perform(multipart("/wx/clerk/media/upload")
+ .file(file)
+ .param("usage", "profile")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("视频大小不能超过30MB"));
+ }
+
+ @Test
+ void uploadRejectsVideoDurationOver45Seconds__covers_MED_004() throws Exception {
+ ensureTenantContext();
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ Path videoPath = Files.createTempFile("apitest-long-video-", ".mp4");
+ try {
+ Process process = new ProcessBuilder(
+ "ffmpeg",
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=black:s=16x16:r=1",
+ "-t",
+ "46",
+ "-pix_fmt",
+ "yuv420p",
+ videoPath.toString())
+ .redirectErrorStream(true)
+ .start();
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new AssertionError("ffmpeg failed with exitCode=" + exitCode + ": "
+ + new String(process.getInputStream().readAllBytes()));
+ }
+
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "long.mp4",
+ "video/mp4",
+ Files.readAllBytes(videoPath));
+
+ mockMvc.perform(multipart("/wx/clerk/media/upload")
+ .file(file)
+ .param("usage", "profile")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("视频时长不能超过45秒"));
+ } finally {
+ Files.deleteIfExists(videoPath);
+ }
+ }
+
+ @Test
+ void uploadPinsDbUniqueConstraintOnClerkUsageMedia__covers_MED_009() throws Exception {
+ ensureTenantContext();
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
+ .thenReturn("https://oss.mock/apitest/dup.png");
+ String mediaId = extractMediaIdFromUpload(buildTinyPng("dup.png"), clerkToken);
+ assertThat(mediaId).isNotBlank();
+
+ PlayClerkMediaAssetEntity duplicate = new PlayClerkMediaAssetEntity();
+ duplicate.setId("asset-dup-" + IdUtils.getUuid().substring(0, 8));
+ duplicate.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ duplicate.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ duplicate.setUsage(ClerkMediaUsage.PROFILE.getCode());
+ duplicate.setMediaId(mediaId);
+ duplicate.setReviewState(ClerkMediaReviewState.DRAFT.getCode());
+ duplicate.setDeleted(false);
+
+ assertThatThrownBy(() -> mediaAssetService.save(duplicate))
+ .isInstanceOf(DuplicateKeyException.class);
+ }
+
@Test
void clerkCanUploadVideoMediaAndPersistUrl() throws Exception {
ensureTenantContext();
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerEndpointsApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerEndpointsApiTest.java
new file mode 100644
index 0000000..f783617
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerEndpointsApiTest.java
@@ -0,0 +1,276 @@
+package com.starry.admin.api;
+
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.common.oss.service.IOssFileService;
+import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.media.entity.PlayMediaEntity;
+import com.starry.admin.modules.media.enums.MediaOwnerType;
+import com.starry.admin.modules.media.enums.MediaStatus;
+import com.starry.admin.modules.media.service.IPlayMediaService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import javax.imageio.ImageIO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxClerkMediaControllerEndpointsApiTest extends AbstractApiTest {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private IPlayClerkMediaAssetService clerkMediaAssetService;
+
+ @Autowired
+ private IPlayMediaService mediaService;
+
+ @MockBean
+ private IOssFileService ossFileService;
+
+ @BeforeEach
+ void setUp() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ }
+
+ @Test
+ void updateOrderDistinctsMediaIdsAndPersistsOrderIndex__covers_MED_005() throws Exception {
+ String suffix = Long.toString(System.nanoTime(), 36);
+ String clerkId = ensureActiveClerk("cm-end-" + suffix, "oid-cm-end-" + suffix);
+ String clerkToken = wxTokenService.createWxUserToken(clerkId);
+ clerkUserInfoService.updateTokenById(clerkId, clerkToken);
+
+ when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
+ .thenReturn(
+ "https://oss.mock/apitest/media-a.png",
+ "https://oss.mock/apitest/media-b.png");
+
+ String mediaIdA = uploadTinyPng("media-a.png", clerkToken);
+ String mediaIdB = uploadTinyPng("media-b.png", clerkToken);
+
+ String payload = "{\"usage\":\"profile\",\"mediaIds\":[\"" + mediaIdB + "\",\"" + mediaIdA + "\",\"" + mediaIdB
+ + "\"]}";
+ mockMvc.perform(put("/wx/clerk/media/order")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ List assets = clerkMediaAssetService.lambdaQuery()
+ .eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
+ .eq(PlayClerkMediaAssetEntity::getUsage, "profile")
+ .eq(PlayClerkMediaAssetEntity::getDeleted, false)
+ .in(PlayClerkMediaAssetEntity::getMediaId, List.of(mediaIdA, mediaIdB))
+ .list();
+ if (assets.size() != 2) {
+ throw new AssertionError("Expected exactly 2 active assets after distinct ordering");
+ }
+ PlayClerkMediaAssetEntity assetA = assets.stream().filter(a -> mediaIdA.equals(a.getMediaId())).findFirst()
+ .orElseThrow();
+ PlayClerkMediaAssetEntity assetB = assets.stream().filter(a -> mediaIdB.equals(a.getMediaId())).findFirst()
+ .orElseThrow();
+ if (!Integer.valueOf(1).equals(assetA.getOrderIndex())) {
+ throw new AssertionError("Expected orderIndex=1 for media-a");
+ }
+ if (!Integer.valueOf(0).equals(assetB.getOrderIndex())) {
+ throw new AssertionError("Expected orderIndex=0 for media-b");
+ }
+ }
+
+ @Test
+ void deleteMarksAssetRejectedAndKeepsDeletedFalse__covers_MED_006() throws Exception {
+ String suffix = Long.toString(System.nanoTime(), 36);
+ String clerkId = ensureActiveClerk("cm-del-" + suffix, "oid-cm-del-" + suffix);
+ String clerkToken = wxTokenService.createWxUserToken(clerkId);
+ clerkUserInfoService.updateTokenById(clerkId, clerkToken);
+
+ when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
+ .thenReturn("https://oss.mock/apitest/media-delete.png");
+
+ String mediaId = uploadTinyPng("media-delete.png", clerkToken);
+
+ PlayClerkMediaAssetEntity beforeDelete = clerkMediaAssetService.lambdaQuery()
+ .eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
+ .eq(PlayClerkMediaAssetEntity::getMediaId, mediaId)
+ .one();
+ if (beforeDelete == null) {
+ throw new AssertionError("Expected clerk asset to exist before delete");
+ }
+ if (beforeDelete.getDeleted() == null) {
+ beforeDelete.setDeleted(false);
+ clerkMediaAssetService.updateById(beforeDelete);
+ }
+
+ mockMvc.perform(delete("/wx/clerk/media/" + mediaId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ List assetsAfterDelete = clerkMediaAssetService.lambdaQuery()
+ .eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
+ .eq(PlayClerkMediaAssetEntity::getMediaId, mediaId)
+ .list();
+ if (assetsAfterDelete.isEmpty()) {
+ throw new AssertionError("Expected clerk asset to exist");
+ }
+ boolean anyRejected = assetsAfterDelete.stream()
+ .anyMatch(item -> ClerkMediaReviewState.REJECTED.getCode().equals(item.getReviewState()));
+ if (!anyRejected) {
+ throw new AssertionError("Expected asset.reviewState=rejected; got: "
+ + assetsAfterDelete.stream()
+ .map(item -> item.getId() + "(deleted=" + item.getDeleted() + ", state="
+ + item.getReviewState() + ")")
+ .collect(java.util.stream.Collectors.toList()));
+ }
+ boolean anyDeleted = assetsAfterDelete.stream().anyMatch(item -> Boolean.TRUE.equals(item.getDeleted()));
+ if (anyDeleted) {
+ throw new AssertionError("Expected asset.deleted to remain false; got: "
+ + assetsAfterDelete.stream()
+ .map(item -> item.getId() + "(deleted=" + item.getDeleted() + ", state="
+ + item.getReviewState() + ")")
+ .collect(java.util.stream.Collectors.toList()));
+ }
+
+ PlayMediaEntity media = mediaService.getById(mediaId);
+ if (media == null) {
+ throw new AssertionError("Expected media to exist");
+ }
+ if (!MediaStatus.REJECTED.getCode().equals(media.getStatus())) {
+ throw new AssertionError("Expected media.status=rejected after delete");
+ }
+ }
+
+ @Test
+ void listDraftReturnsOnlyDraftPendingRejected__covers_MED_007__covers_MED_008() throws Exception {
+ String suffix = Long.toString(System.nanoTime(), 36);
+ String clerkId = ensureActiveClerk("cm-lst-" + suffix, "oid-cm-lst-" + suffix);
+ String clerkToken = wxTokenService.createWxUserToken(clerkId);
+ clerkUserInfoService.updateTokenById(clerkId, clerkToken);
+
+ when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
+ .thenReturn(
+ "https://oss.mock/apitest/media-draft.png",
+ "https://oss.mock/apitest/media-approved.png");
+
+ String draftMediaId = uploadTinyPng("media-draft.png", clerkToken);
+ String approvedMediaId = uploadTinyPng("media-approved.png", clerkToken);
+
+ List assets = clerkMediaAssetService.lambdaQuery()
+ .eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
+ .eq(PlayClerkMediaAssetEntity::getUsage, "profile")
+ .eq(PlayClerkMediaAssetEntity::getDeleted, false)
+ .in(PlayClerkMediaAssetEntity::getMediaId, List.of(draftMediaId, approvedMediaId))
+ .list();
+ PlayClerkMediaAssetEntity draftAsset = assets.stream().filter(a -> draftMediaId.equals(a.getMediaId()))
+ .findFirst().orElseThrow();
+ PlayClerkMediaAssetEntity approvedAsset = assets.stream().filter(a -> approvedMediaId.equals(a.getMediaId()))
+ .findFirst().orElseThrow();
+
+ draftAsset.setReviewState(ClerkMediaReviewState.DRAFT.getCode());
+ approvedAsset.setReviewState(ClerkMediaReviewState.APPROVED.getCode());
+ clerkMediaAssetService.updateBatchById(List.of(draftAsset, approvedAsset));
+
+ mockMvc.perform(get("/wx/clerk/media/list")
+ .param("usage", "profile")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[*].mediaId").value(containsInAnyOrder(draftMediaId)));
+
+ mockMvc.perform(get("/wx/clerk/media/approved")
+ .param("usage", "profile")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[*].mediaId").value(containsInAnyOrder(approvedMediaId)));
+ }
+
+ private String ensureActiveClerk(String clerkId, String openId) {
+ PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
+ if (existing == null) {
+ PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
+ entity.setId(clerkId);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setOpenid(openId);
+ entity.setNickname("API Test Clerk Media");
+ entity.setAvatar("https://example.com/avatar.png");
+ entity.setSysUserId("");
+ entity.setOnboardingState("1");
+ entity.setListingState("1");
+ entity.setClerkState("1");
+ entity.setOnlineState("1");
+ clerkUserInfoService.save(entity);
+ return clerkId;
+ }
+ PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
+ patch.setId(clerkId);
+ patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ patch.setOpenid(openId);
+ patch.setDeleted(Boolean.FALSE);
+ patch.setSysUserId("");
+ patch.setOnboardingState("1");
+ patch.setListingState("1");
+ patch.setClerkState("1");
+ patch.setOnlineState("1");
+ clerkUserInfoService.updateById(patch);
+ return clerkId;
+ }
+
+ private String uploadTinyPng(String filename, String clerkToken) throws Exception {
+ BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", baos);
+ MockMultipartFile file = new MockMultipartFile("file", filename, "image/png", baos.toByteArray());
+
+ MvcResult result = mockMvc.perform(multipart("/wx/clerk/media/upload")
+ .file(file)
+ .param("usage", "profile")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+ return OBJECT_MAPPER.readTree(result.getResponse().getContentAsString()).path("data").path("mediaId").asText();
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxClerkOrderCompleteApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxClerkOrderCompleteApiTest.java
new file mode 100644
index 0000000..db7fe84
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxClerkOrderCompleteApiTest.java
@@ -0,0 +1,193 @@
+package com.starry.admin.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl;
+import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
+import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
+import com.starry.admin.modules.personnel.service.IPlayPersonnelAdminInfoService;
+import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.context.CustomSecurityContextHolder;
+import com.starry.common.utils.IdUtils;
+import java.time.LocalDateTime;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+
+class WxClerkOrderCompleteApiTest extends AbstractApiTest {
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private IPlayPersonnelAdminInfoService personnelAdminInfoService;
+
+ @Autowired
+ private IPlayPersonnelGroupInfoService personnelGroupInfoService;
+
+ @MockBean
+ private PlayOrderInfoServiceImpl orderInfoService;
+
+ @AfterEach
+ void tearDown() {
+ CustomSecurityContextHolder.remove();
+ }
+
+ @Test
+ void completeOrderRejectsWhenSysUserIdMissing__covers_CLK_017() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ String clerkId = "clerk-complete-nosys-" + IdUtils.getUuid().substring(0, 8);
+ ensureClerk(clerkId, "openid-" + clerkId, "");
+ String token = ensureClerkToken(clerkId);
+
+ String payload = "{\"orderId\":\"order-any\",\"remark\":\"\"}";
+ mockMvc.perform(post("/wx/clerk/order/complete")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("账号未绑定系统用户,无法完成订单"));
+ }
+
+ @Test
+ void completeOrderChoosesAdminOperatorType__covers_CLK_018() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ String sysUserId = "sys-admin-" + IdUtils.getUuid().substring(0, 8);
+ PlayPersonnelAdminInfoEntity admin = new PlayPersonnelAdminInfoEntity();
+ admin.setId("admin-" + IdUtils.getUuid().substring(0, 8));
+ admin.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ admin.setSysUserId(sysUserId);
+ admin.setAdminName("admin");
+ admin.setAddTime(LocalDateTime.now());
+ personnelAdminInfoService.save(admin);
+
+ String clerkId = "clerk-complete-admin-" + IdUtils.getUuid().substring(0, 8);
+ ensureClerk(clerkId, "openid-" + clerkId, sysUserId);
+ String token = ensureClerkToken(clerkId);
+
+ doNothing().when(orderInfoService).completeOrderByManagement(
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.any());
+
+ String orderId = "order-admin-" + IdUtils.getUuid().substring(0, 8);
+ String payload = "{\"orderId\":\"" + orderId + "\",\"remark\":\"admin\"}";
+ mockMvc.perform(post("/wx/clerk/order/complete")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("操作成功"));
+
+ ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor operatorCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(String.class);
+ verify(orderInfoService).completeOrderByManagement(
+ typeCaptor.capture(),
+ operatorCaptor.capture(),
+ orderCaptor.capture(),
+ org.mockito.ArgumentMatchers.any());
+ assertThat(typeCaptor.getValue()).isEqualTo("2");
+ assertThat(operatorCaptor.getValue()).isEqualTo(sysUserId);
+ assertThat(orderCaptor.getValue()).isEqualTo(orderId);
+ }
+
+ @Test
+ void completeOrderChoosesGroupLeaderOperatorType__covers_CLK_018() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ String sysUserId = "sys-leader-" + IdUtils.getUuid().substring(0, 8);
+ PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
+ group.setId("group-" + IdUtils.getUuid().substring(0, 8));
+ group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ group.setSysUserId(sysUserId);
+ group.setGroupName("group");
+ group.setLeaderName("leader");
+ group.setAddTime(LocalDateTime.now());
+ personnelGroupInfoService.save(group);
+
+ String clerkId = "clerk-complete-leader-" + IdUtils.getUuid().substring(0, 8);
+ ensureClerk(clerkId, "openid-" + clerkId, sysUserId);
+ String token = ensureClerkToken(clerkId);
+
+ doNothing().when(orderInfoService).completeOrderByManagement(
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.any());
+
+ String orderId = "order-leader-" + IdUtils.getUuid().substring(0, 8);
+ String payload = "{\"orderId\":\"" + orderId + "\",\"remark\":\"leader\"}";
+ mockMvc.perform(post("/wx/clerk/order/complete")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("操作成功"));
+
+ ArgumentCaptor typeCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor operatorCaptor = ArgumentCaptor.forClass(String.class);
+ verify(orderInfoService).completeOrderByManagement(
+ typeCaptor.capture(),
+ operatorCaptor.capture(),
+ org.mockito.ArgumentMatchers.eq(orderId),
+ org.mockito.ArgumentMatchers.any());
+ assertThat(typeCaptor.getValue()).isEqualTo("3");
+ assertThat(operatorCaptor.getValue()).isEqualTo(clerkId);
+ }
+
+ private String ensureClerkToken(String clerkId) {
+ String token = wxTokenService.createWxUserToken(clerkId);
+ clerkUserInfoService.updateTokenById(clerkId, token);
+ return token;
+ }
+
+ private void ensureClerk(String clerkId, String openid, String sysUserId) {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
+ entity.setId(clerkId);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setOpenid(openid);
+ entity.setNickname("API Clerk " + clerkId);
+ entity.setSysUserId(sysUserId);
+ entity.setOnboardingState("1");
+ entity.setListingState("1");
+ entity.setClerkState("1");
+ entity.setOnlineState("1");
+ if (clerkUserInfoService.getById(clerkId) == null) {
+ clerkUserInfoService.save(entity);
+ } else {
+ entity.setDeleted(Boolean.FALSE);
+ clerkUserInfoService.updateById(entity);
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxClerkWagesControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxClerkWagesControllerApiTest.java
new file mode 100644
index 0000000..2907c3f
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxClerkWagesControllerApiTest.java
@@ -0,0 +1,203 @@
+package com.starry.admin.api;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkWagesInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkWagesDetailsInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkWagesInfoService;
+import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
+import com.starry.admin.modules.order.service.IPlayOrderInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.utils.IdUtils;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+
+class WxClerkWagesControllerApiTest extends AbstractApiTest {
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private IPlayOrderInfoService orderInfoService;
+
+ @Autowired
+ private IPlayClerkWagesInfoService wagesInfoService;
+
+ @Autowired
+ private IPlayClerkWagesDetailsInfoService wagesDetailsInfoService;
+
+ private final java.util.List orderIdsToCleanup = new java.util.ArrayList<>();
+ private final java.util.List wagesIdsToCleanup = new java.util.ArrayList<>();
+ private String clerkToken;
+ private String clerkId;
+
+ @BeforeEach
+ void setUp() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String suffix = Long.toString(System.nanoTime(), 36);
+ clerkId = "apitest-wage-clerk-" + suffix;
+ String openId = "openid-" + clerkId;
+ clerkToken = wxTokenService.createWxUserToken(clerkId);
+ ensureActiveClerk(clerkId, openId, clerkToken);
+
+ wagesDetailsInfoService.lambdaUpdate()
+ .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkWagesDetailsInfoEntity::getClerkId,
+ clerkId)
+ .remove();
+ wagesInfoService.lambdaUpdate()
+ .eq(PlayClerkWagesInfoEntity::getClerkId, clerkId)
+ .remove();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ if (!orderIdsToCleanup.isEmpty()) {
+ orderInfoService.removeByIds(orderIdsToCleanup);
+ orderIdsToCleanup.clear();
+ }
+ if (!wagesIdsToCleanup.isEmpty()) {
+ wagesInfoService.removeByIds(wagesIdsToCleanup);
+ wagesIdsToCleanup.clear();
+ }
+ }
+
+ @Test
+ void queryUnsettledWagesSumsOrdersForClerk__covers_WAGE_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String orderA = insertUnsettledOrder(new BigDecimal("10.00"), new BigDecimal("3.00"));
+ String orderB = insertUnsettledOrder(new BigDecimal("20.50"), new BigDecimal("6.50"));
+ orderIdsToCleanup.add(orderA);
+ orderIdsToCleanup.add(orderB);
+
+ mockMvc.perform(get("/wx/wages/clerk/queryUnsettledWages")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.orderNumber").value(2))
+ .andExpect(jsonPath("$.data.orderMoney").value(30.50))
+ .andExpect(jsonPath("$.data.estimatedRevenue").value(9.50));
+ }
+
+ @Test
+ void queryCurrentPeriodWagesReturnsZerosWhenNoRow__covers_WAGE_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(get("/wx/wages/clerk/queryCurrentPeriodWages")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.totalMoney").value(0))
+ .andExpect(jsonPath("$.data.orderWages.orderNumber").value(0))
+ .andExpect(jsonPath("$.data.startCountDate").value(LocalDate.now().toString()))
+ .andExpect(jsonPath("$.data.endCountDate").value(LocalDate.now().toString()));
+ }
+
+ @Test
+ void queryHistoricalWagesReturnsHardcodedPageMeta__covers_WAGE_003() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ insertHistoricalWages("apitest-wage-his-1", new BigDecimal("12.34"));
+ insertHistoricalWages("apitest-wage-his-2", new BigDecimal("56.78"));
+
+ mockMvc.perform(post("/wx/wages/clerk/queryHistoricalWages")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.total").value(5))
+ .andExpect(jsonPath("$.pageInfo.pageSize").value(10))
+ .andExpect(jsonPath("$.pageInfo.totalPage").value(1))
+ .andExpect(jsonPath("$.data").isArray())
+ .andExpect(jsonPath("$.data.length()").value(2));
+ }
+
+ private String insertUnsettledOrder(BigDecimal finalAmount, BigDecimal estimatedRevenue) {
+ PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
+ String id = "apitest-wage-" + IdUtils.getUuid();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setAcceptBy(clerkId);
+ entity.setOrderSettlementState("0");
+ entity.setFinalAmount(finalAmount);
+ entity.setEstimatedRevenue(estimatedRevenue);
+ entity.setDeleted(false);
+ orderInfoService.save(entity);
+ return id;
+ }
+
+ private void insertHistoricalWages(String idPrefix, BigDecimal finalAmount) {
+ PlayClerkWagesInfoEntity entity = new PlayClerkWagesInfoEntity();
+ String id = idPrefix + "-" + IdUtils.getUuid();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setClerkId(clerkId);
+ entity.setHistoricalStatistics("1");
+ entity.setStartCountDate(LocalDate.now().minusDays(7));
+ entity.setEndCountDate(LocalDate.now().minusDays(1));
+ entity.setSettlementDate(LocalDate.now().minusDays(1));
+ entity.setOrderNumber(1);
+ entity.setFinalAmount(finalAmount);
+ entity.setEstimatedRevenue(finalAmount);
+ entity.setCreatedTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
+ entity.setUpdatedTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
+ entity.setDeleted(false);
+ wagesInfoService.save(entity);
+ wagesIdsToCleanup.add(id);
+ }
+
+ private void ensureActiveClerk(String clerkId, String openId, String token) {
+ PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
+ if (existing == null) {
+ PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
+ entity.setId(clerkId);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setOpenid(openId);
+ entity.setNickname("API Test Wages Clerk");
+ entity.setAvatar("https://example.com/avatar.png");
+ entity.setSysUserId("");
+ entity.setOnboardingState("1");
+ entity.setListingState("1");
+ entity.setClerkState("1");
+ entity.setOnlineState("1");
+ entity.setToken(token);
+ clerkUserInfoService.save(entity);
+ return;
+ }
+ PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
+ patch.setId(clerkId);
+ patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ patch.setOpenid(openId);
+ patch.setSysUserId("");
+ patch.setOnboardingState("1");
+ patch.setListingState("1");
+ patch.setClerkState("1");
+ patch.setOnlineState("1");
+ patch.setDeleted(Boolean.FALSE);
+ patch.setToken(token);
+ clerkUserInfoService.updateById(patch);
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerApiTest.java
new file mode 100644
index 0000000..57a0afc
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerApiTest.java
@@ -0,0 +1,93 @@
+package com.starry.admin.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.common.oss.service.IOssFileService;
+import com.starry.common.context.CustomSecurityContextHolder;
+import com.starry.common.utils.IdUtils;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxCommonControllerApiTest extends AbstractApiTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @MockBean
+ private IOssFileService ossFileService;
+
+ @AfterEach
+ void tearDown() {
+ CustomSecurityContextHolder.remove();
+ }
+
+ @Test
+ void areaTreeReturnsTreeSchema__covers_COM_001() throws Exception {
+ mockMvc.perform(get("/wx/common/area/tree")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").exists());
+ }
+
+ @Test
+ void settingInfoReturnsGlobalRowRegardlessOfTenant__covers_COM_002() throws Exception {
+ MvcResult first = mockMvc.perform(get("/wx/common/setting/info")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, "tenant-a-" + IdUtils.getUuid().substring(0, 6)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+ JsonNode firstRoot = objectMapper.readTree(first.getResponse().getContentAsString(StandardCharsets.UTF_8));
+ String firstId = firstRoot.path("data").path("id").asText();
+ assertThat(firstId).isNotBlank();
+
+ MvcResult second = mockMvc.perform(get("/wx/common/setting/info")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, "tenant-b-" + IdUtils.getUuid().substring(0, 6)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+ JsonNode secondRoot = objectMapper.readTree(second.getResponse().getContentAsString(StandardCharsets.UTF_8));
+ String secondId = secondRoot.path("data").path("id").asText();
+ assertThat(secondId).isNotEqualTo(firstId);
+ }
+
+ @Test
+ void fileUploadReturnsOssUrl__covers_COM_003() throws Exception {
+ String url = "https://oss.mock/apitest/common-upload.png";
+ when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
+ .thenReturn(url);
+
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "common-upload.png",
+ MediaType.IMAGE_PNG_VALUE,
+ new byte[] {0x01, 0x02, 0x03});
+
+ mockMvc.perform(multipart("/wx/common/file/upload")
+ .file(file)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.MULTIPART_FORM_DATA))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value(url));
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerAudioUploadApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerAudioUploadApiTest.java
new file mode 100644
index 0000000..f85ca32
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerAudioUploadApiTest.java
@@ -0,0 +1,74 @@
+package com.starry.admin.api;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.starry.admin.common.exception.CustomException;
+import com.starry.admin.modules.weichat.service.WxAccessTokenService;
+import com.starry.admin.modules.weichat.utils.WxFileUtils;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.redis.RedisCache;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.nio.file.Files;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+
+class WxCommonControllerAudioUploadApiTest extends AbstractApiTest {
+
+ @MockBean
+ private com.starry.admin.common.oss.service.IOssFileService ossFileService;
+
+ @MockBean
+ private WxAccessTokenService wxAccessTokenService;
+
+ @MockBean
+ private RedisCache redisCache;
+
+ @Test
+ void audioUploadRejectsBlankMediaId__covers_COM_004() throws Exception {
+ SecurityUtils.setTenantId("tenant-apitest");
+
+ mockMvc.perform(get("/wx/common/audio/upload")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .param("mediaId", ""))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500));
+ }
+
+ @Test
+ void audioUploadDownloadsConvertsAndUploadsMp3__covers_COM_005() throws Exception {
+ SecurityUtils.setTenantId("tenant-apitest");
+ when(wxAccessTokenService.getAccessToken()).thenReturn("access-token");
+ when(ossFileService.upload(any(), eq("tenant-apitest"), anyString())).thenReturn("https://oss.example/audio.mp3");
+
+ try (MockedStatic mocked = Mockito.mockStatic(WxFileUtils.class)) {
+ mocked.when(() -> WxFileUtils.getTemporaryMaterial(eq("access-token"), eq("media-1")))
+ .thenReturn(new ByteArrayInputStream("amr-bytes".getBytes()));
+ mocked.when(() -> WxFileUtils.audioConvert2Mp3(any(File.class), any(File.class)))
+ .thenAnswer(invocation -> {
+ File target = invocation.getArgument(1);
+ Files.write(target.toPath(), "mp3-bytes".getBytes());
+ return null;
+ });
+
+ mockMvc.perform(get("/wx/common/audio/upload")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .param("mediaId", "media-1")
+ .accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("https://oss.example/audio.mp3"));
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCouponControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCouponControllerApiTest.java
index ca89a8d..ba63f3a 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxCouponControllerApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCouponControllerApiTest.java
@@ -102,7 +102,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
}
@Test
- void obtainCouponRejectsNonWhitelistCustomer() throws Exception {
+ void obtainCouponReturnsNonEligibilityReasonWhenNonWhitelisted__covers_CP_002() throws Exception {
ensureTenantContext();
String couponId = newCouponId("whitelist");
PlayCouponInfoEntity coupon = createBaseCoupon(couponId);
@@ -123,7 +123,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
}
@Test
- void queryAllSkipsOfflineCouponsAndMarksObtainedOnes() throws Exception {
+ void queryAllSkipsOfflineCouponsAndMarksObtainedOnes__covers_CP_004() throws Exception {
ensureTenantContext();
PlayCouponInfoEntity onlineCoupon = createBaseCoupon(newCouponId("online"));
onlineCoupon.setCustomWhitelist(List.of(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID));
@@ -164,7 +164,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
}
@Test
- void queryByOrderFlagsOnlyEligibleCouponsAsAvailable() throws Exception {
+ void queryByOrderFlagsOnlyEligibleCouponsAsAvailable__covers_CP_006() throws Exception {
ensureTenantContext();
PlayCouponInfoEntity eligible = createBaseCoupon(newCouponId("eligible"));
eligible.setUseMinAmount(MINIMUM_USAGE_AMOUNT);
@@ -200,23 +200,30 @@ class WxCouponControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.code").value(200))
.andReturn();
- ArrayNode data = (ArrayNode) mapper.readTree(result.getResponse().getContentAsString()).path("data");
+ ArrayNode data = (ArrayNode) mapper.readTree(result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8))
+ .path("data");
assertThat(data).isNotNull();
assertThat(data.size()).isGreaterThanOrEqualTo(2);
+ boolean foundEligibleDetail = false;
+ boolean foundIneligibleDetail = false;
for (JsonNode node : data) {
- if (eligible.getId().equals(node.path("couponId").asText())) {
+ if (eligibleDetail.getId().equals(node.path("id").asText())) {
+ foundEligibleDetail = true;
assertThat(node.path("available").asText()).isEqualTo("1");
assertThat(node.path("reasonForUnavailableUse").asText("")).isEmpty();
}
- if (ineligible.getId().equals(node.path("couponId").asText())) {
+ if (ineligibleDetail.getId().equals(node.path("id").asText())) {
+ foundIneligibleDetail = true;
assertThat(node.path("available").asText()).isEqualTo("0");
assertThat(node.path("reasonForUnavailableUse").asText()).isEqualTo("订单类型不符合");
}
}
+ assertThat(foundEligibleDetail).isTrue();
+ assertThat(foundIneligibleDetail).isTrue();
}
@Test
- void obtainCouponSucceedsWhenEligible() throws Exception {
+ void obtainCouponSucceedsWhenEligible__covers_CP_003() throws Exception {
ensureTenantContext();
PlayCouponInfoEntity coupon = createBaseCoupon(newCouponId("success"));
couponInfoService.save(coupon);
@@ -244,7 +251,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
}
@Test
- void obtainCouponHonorsPerUserLimit() throws Exception {
+ void obtainCouponHonorsPerUserLimit__covers_CP_002() throws Exception {
ensureTenantContext();
PlayCouponInfoEntity coupon = createBaseCoupon(newCouponId("limit"));
coupon.setClerkObtainedMaxQuantity(1);
@@ -279,6 +286,121 @@ class WxCouponControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.data.msg").value("优惠券已达到领取上限"));
}
+ @Test
+ void obtainCouponRejectsEmptyId__covers_CP_001() throws Exception {
+ ensureTenantContext();
+
+ mockMvc.perform(get("/wx/coupon/custom/obtainCoupon")
+ .param("id", "")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请求参数异常,优惠券ID不能为"));
+ }
+
+ @Test
+ void queryAllHidesWhitelistCouponFromNonWhitelistCustomer__covers_CP_004() throws Exception {
+ ensureTenantContext();
+
+ String couponId = newCouponId("qall-wl");
+ PlayCouponInfoEntity coupon = createBaseCoupon(couponId);
+ coupon.setClaimConditionType(CouponClaimConditionType.WHITELIST.code());
+ coupon.setCustomWhitelist(List.of("other-customer"));
+ couponInfoService.save(coupon);
+ couponIds.add(coupon.getId());
+
+ MvcResult result = mockMvc.perform(post("/wx/coupon/custom/queryAll")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode data = mapper.readTree(result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8))
+ .path("data");
+ assertThat(data).isNotNull();
+ assertThat(data.isArray()).isTrue();
+ for (JsonNode node : data) {
+ assertThat(node.path("id").asText()).isNotEqualTo(coupon.getId());
+ }
+ }
+
+ @Test
+ void queryByOrderRejectsWhenClerkIdAndLevelIdBothEmpty__covers_CP_005() throws Exception {
+ ensureTenantContext();
+
+ ObjectNode payload = mapper.createObjectNode();
+ payload.put("commodityId", ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
+ payload.put("levelId", "");
+ payload.put("clerkId", "");
+ payload.put("placeType", OrderConstant.PlaceType.RANDOM.getCode());
+ payload.put("commodityQuantity", 1);
+
+ mockMvc.perform(post("/wx/coupon/custom/queryByOrder")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请求参数异常,店员ID不能为空,等级ID不能为空"));
+ }
+
+ @Test
+ void queryByOrderSwallowsBrokenCouponAndReturnsRemaining__covers_CP_007() throws Exception {
+ ensureTenantContext();
+ PlayCouponInfoEntity eligible = createBaseCoupon(newCouponId("swv"));
+ eligible.setUseMinAmount(BigDecimal.ZERO);
+ couponInfoService.save(eligible);
+ couponIds.add(eligible.getId());
+
+ PlayCouponDetailsEntity eligibleDetail = createCouponDetail(eligible.getId(), CouponUseState.UNUSED);
+ couponDetailsService.save(eligibleDetail);
+ couponDetailIds.add(eligibleDetail.getId());
+
+ PlayCouponDetailsEntity brokenDetail = createCouponDetail(newCouponId("missing"), CouponUseState.UNUSED);
+ couponDetailsService.save(brokenDetail);
+ couponDetailIds.add(brokenDetail.getId());
+
+ ObjectNode payload = mapper.createObjectNode();
+ payload.put("commodityId", ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
+ payload.put("levelId", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
+ payload.put("clerkId", "");
+ payload.put("placeType", OrderConstant.PlaceType.RANDOM.getCode());
+ payload.put("commodityQuantity", 1);
+
+ MvcResult result = mockMvc.perform(post("/wx/coupon/custom/queryByOrder")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode data = mapper.readTree(result.getResponse().getContentAsString()).path("data");
+ assertThat(data.isArray()).isTrue();
+ boolean foundEligibleDetail = false;
+ boolean foundBrokenDetail = false;
+ for (JsonNode node : data) {
+ String returnedId = node.path("id").asText();
+ if (eligibleDetail.getId().equals(returnedId)) {
+ foundEligibleDetail = true;
+ }
+ if (brokenDetail.getId().equals(returnedId)) {
+ foundBrokenDetail = true;
+ }
+ }
+ assertThat(foundEligibleDetail).isTrue();
+ assertThat(foundBrokenDetail).isFalse();
+ }
+
private PlayCouponInfoEntity createBaseCoupon(String id) {
PlayCouponInfoEntity coupon = new PlayCouponInfoEntity();
coupon.setId(id);
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomControllerMiscApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomControllerMiscApiTest.java
new file mode 100644
index 0000000..b8faea6
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomControllerMiscApiTest.java
@@ -0,0 +1,277 @@
+package com.starry.admin.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity;
+import com.starry.admin.modules.custom.module.entity.PlayCustomLeaveMsgEntity;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
+import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService;
+import com.starry.admin.modules.custom.service.IPlayCustomLeaveMsgService;
+import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
+import com.starry.admin.modules.order.module.constant.OrderConstant;
+import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
+import com.starry.admin.modules.order.service.IPlayOrderInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.context.CustomSecurityContextHolder;
+import com.starry.common.utils.IdUtils;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxCustomControllerMiscApiTest extends AbstractApiTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayCustomUserInfoService customUserInfoService;
+
+ @Autowired
+ private IPlayCustomLeaveMsgService leaveMsgService;
+
+ @Autowired
+ private IPlayCustomFollowInfoService followInfoService;
+
+ @Autowired
+ private IPlayOrderInfoService orderInfoService;
+
+ @AfterEach
+ void tearDown() {
+ CustomSecurityContextHolder.remove();
+ }
+
+ @Test
+ void queryClerkDetailedByIdWorksWithoutLogin__covers_CUS_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(get("/wx/custom/queryClerkDetailedById")
+ .param("id", ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").exists());
+ }
+
+ @Test
+ void queryByIdReturnsClerkStateWhenOpenidMatchesClerk__covers_CUS_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String customerId = "customer-openid-match-" + IdUtils.getUuid().substring(0, 8);
+ String token = ensureCustomerWithOpenid(customerId, ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID);
+
+ mockMvc.perform(get("/wx/custom/queryById")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.clerkState").value("1"));
+ }
+
+ @Test
+ void updateHideLevelStateIgnoresClientIdAndUsesSessionId__covers_CUS_003() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String sessionCustomerId = "customer-hide-session-" + IdUtils.getUuid().substring(0, 8);
+ String otherCustomerId = "customer-hide-other-" + IdUtils.getUuid().substring(0, 8);
+ String sessionToken = ensureCustomerWithOpenid(sessionCustomerId, "openid-" + sessionCustomerId);
+ ensureCustomerWithOpenid(otherCustomerId, "openid-" + otherCustomerId);
+
+ customUserInfoService.lambdaUpdate()
+ .eq(PlayCustomUserInfoEntity::getId, sessionCustomerId)
+ .set(PlayCustomUserInfoEntity::getHideLevelState, "0")
+ .update();
+ customUserInfoService.lambdaUpdate()
+ .eq(PlayCustomUserInfoEntity::getId, otherCustomerId)
+ .set(PlayCustomUserInfoEntity::getHideLevelState, "0")
+ .update();
+
+ String payload = "{\"id\":\"" + otherCustomerId + "\",\"hideLevelState\":\"1\"}";
+ mockMvc.perform(post("/wx/custom/updateHideLevelState")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + sessionToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayCustomUserInfoEntity sessionAfter = customUserInfoService.getById(sessionCustomerId);
+ PlayCustomUserInfoEntity otherAfter = customUserInfoService.getById(otherCustomerId);
+ assertThat(sessionAfter.getHideLevelState()).isEqualTo("1");
+ assertThat(otherAfter.getHideLevelState()).isEqualTo("0");
+ }
+
+ @Test
+ void leaveAddCreatesRowAndReturnsPinnedMessage__covers_CUS_015() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String customerId = "customer-leave-" + IdUtils.getUuid().substring(0, 8);
+ String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
+
+ String content = "leave-content-" + LocalDateTime.now();
+ String payload = "{\"content\":\"" + content + "\",\"images\":[],\"remark\":\"\"}";
+
+ mockMvc.perform(post("/wx/custom/leave/add")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("取消成功"));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayCustomLeaveMsgEntity latest = leaveMsgService.lambdaQuery()
+ .eq(PlayCustomLeaveMsgEntity::getCustomId, customerId)
+ .orderByDesc(PlayCustomLeaveMsgEntity::getMsgTime)
+ .last("limit 1")
+ .one();
+ assertThat(latest).isNotNull();
+ assertThat(latest.getContent()).isEqualTo(content);
+ assertThat(latest.getImages()).isNotNull();
+ }
+
+ @Test
+ void leaveQueryPermissionReturnsSchemaPinned__covers_CUS_016() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String customerId = "customer-leave-perm-" + IdUtils.getUuid().substring(0, 8);
+ String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
+
+ mockMvc.perform(get("/wx/custom/leave/queryPermission")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.permission").value(true))
+ .andExpect(jsonPath("$.data.msg").value(""));
+ }
+
+ @Test
+ void followStateUpdateIsIdempotentAndCorrect__covers_CUS_017() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String customerId = "customer-follow-" + IdUtils.getUuid().substring(0, 8);
+ String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
+
+ String payload = "{\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\",\"followState\":\"1\"}";
+ for (int i = 0; i < 2; i++) {
+ mockMvc.perform(post("/wx/custom/followState/update")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("修改成功"));
+ }
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ long rowCount = followInfoService.lambdaQuery()
+ .eq(PlayCustomFollowInfoEntity::getCustomId, customerId)
+ .eq(PlayCustomFollowInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .count();
+ assertThat(rowCount).isEqualTo(1);
+ PlayCustomFollowInfoEntity row = followInfoService.lambdaQuery()
+ .eq(PlayCustomFollowInfoEntity::getCustomId, customerId)
+ .eq(PlayCustomFollowInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .one();
+ assertThat(row.getFollowState()).isEqualTo("1");
+ }
+
+ @Test
+ void followQueryByPageReturnsPagingSchema__covers_CUS_018() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String customerId = "customer-follow-page-" + IdUtils.getUuid().substring(0, 8);
+ String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
+
+ followInfoService.updateFollowState(customerId, ApiTestDataSeeder.DEFAULT_CLERK_ID, "1");
+
+ String payload = "{\"pageNum\":1,\"pageSize\":10,\"followState\":\"1\"}";
+ MvcResult result = mockMvc.perform(post("/wx/custom/follow/queryByPage")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8));
+ assertThat(root.path("data").isArray()).isTrue();
+ assertThat(root.path("pageInfo").isObject()).isTrue();
+ }
+
+ @Test
+ void complaintAddRejectsNonPurchaser__covers_CUS_014() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String purchaserId = "cmpown-" + IdUtils.getUuid().substring(0, 8);
+ ensureCustomerWithOpenid(purchaserId, "openid-" + purchaserId);
+
+ String otherCustomerId = "cmpoth-" + IdUtils.getUuid().substring(0, 8);
+ String otherToken = ensureCustomerWithOpenid(otherCustomerId, "openid-" + otherCustomerId);
+
+ String orderId = "order-complaint-" + IdUtils.getUuid().substring(0, 8);
+ PlayOrderInfoEntity order = new PlayOrderInfoEntity();
+ order.setId(orderId);
+ order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ order.setOrderStatus(OrderConstant.OrderStatus.PENDING.getCode());
+ order.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
+ order.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
+ order.setPurchaserBy(purchaserId);
+ order.setWeiChatCode("wx-" + purchaserId);
+ orderInfoService.save(order);
+
+ String payload = "{\"orderId\":\"" + orderId + "\",\"wxChatCode\":\"wx-complain\",\"complaintCon\":\"bad\","
+ + "\"images\":[]}";
+
+ mockMvc.perform(post("/wx/custom/order/complaint/add")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + otherToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("只有下单人才能投诉"));
+ }
+
+ private String ensureCustomerWithOpenid(String id, String openid) {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setOpenid(openid);
+ entity.setNickname("api-" + id);
+ entity.setAccountBalance(new BigDecimal("0.00"));
+ entity.setAccountState("1");
+ entity.setSubscribeState("1");
+ entity.setPurchaseState("1");
+ entity.setMobilePhoneState("1");
+ customUserInfoService.save(entity);
+
+ String token = wxTokenService.createWxUserToken(id);
+ customUserInfoService.updateTokenById(id, token);
+ return token;
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java
index 4f4bd19..160ed29 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java
@@ -79,7 +79,7 @@ class WxCustomGiftOrderApiTest extends WxCustomOrderApiTestSupport {
@Test
// 测试用例:用户余额充足且携带有效登录态时,请求 /wx/custom/order/gift 下单指定礼物,
// 期望生成已完成的礼物奖励订单、产生对应收益记录,同时校验用户/陪玩师礼物计数与账户余额随订单金额同步更新。
- void giftOrderCreatesCompletedRewardAndUpdatesGiftCounters() throws Exception {
+ void giftOrderCreatesCompletedRewardAndUpdatesGiftCounters__covers_CUS_005() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
resetCustomerBalance();
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderEvaluationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderEvaluationApiTest.java
index 07b056e..6ad8687 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderEvaluationApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderEvaluationApiTest.java
@@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
@@ -17,6 +18,7 @@ import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
+import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -101,6 +103,60 @@ class WxCustomOrderEvaluationApiTest extends WxCustomOrderApiTestSupport {
assertThat(data.path("evaluateCon").asText()).endsWith(evaluationSuffix);
}
+ @Test
+ void evaluateAddRejectsNonPurchaser__covers_CUS_012() throws Exception {
+ ensureTenantContext();
+ String remark = "evaluate-nonpurchaser-" + LocalDateTime.now();
+ String orderId = createRandomOrder(remark);
+
+ String otherCustomerId = "customer-eval-other-" + IdUtils.getUuid().substring(0, 8);
+ PlayCustomUserInfoEntity other = new PlayCustomUserInfoEntity();
+ other.setId(otherCustomerId);
+ other.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ other.setOpenid("openid-" + otherCustomerId);
+ other.setNickname("other");
+ other.setAccountBalance(new BigDecimal("0.00"));
+ other.setAccountState("1");
+ other.setSubscribeState("1");
+ other.setPurchaseState("1");
+ other.setMobilePhoneState("1");
+ customUserInfoService.save(other);
+ String otherToken = wxTokenService.createWxUserToken(otherCustomerId);
+ customUserInfoService.updateTokenById(otherCustomerId, otherToken);
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("orderId", orderId);
+ payload.put("anonymous", "1");
+ payload.put("evaluateLevel", 5);
+ payload.put("evaluateCon", "bad");
+
+ mockMvc.perform(post("/wx/custom/order/evaluate/add")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + otherToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("只有下单人才能评价"));
+ }
+
+ @Test
+ void queryEvaluationRejectsWhenNotEvaluated__covers_CUS_013() throws Exception {
+ ensureTenantContext();
+ String remark = "evaluate-missing-" + LocalDateTime.now();
+ String orderId = createRandomOrder(remark);
+
+ mockMvc.perform(get("/wx/custom/order/evaluate/queryByOrderId")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("当前订单未评价"));
+ }
+
private String createRandomOrder(String remark) throws Exception {
ensureTenantContext();
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java
index a060390..ab0a863 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderQueryApiTest.java
@@ -258,7 +258,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
}
@Test
- void queryByPageReturnsOnlyOrdersBelongingToCurrentCustomer() throws Exception {
+ void queryByPageReturnsOnlyOrdersBelongingToCurrentCustomer__covers_CUS_009() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java
index b4157d8..fcb6dad 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java
@@ -18,6 +18,8 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
+import com.starry.admin.modules.order.module.entity.PlayOrderRefundInfoEntity;
+import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelAdminInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
@@ -71,8 +73,11 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
@org.springframework.beans.factory.annotation.Autowired
private IPlayPersonnelAdminInfoService playPersonnelAdminInfoService;
+ @org.springframework.beans.factory.annotation.Autowired
+ private IPlayOrderRefundInfoService orderRefundInfoService;
+
@Test
- void randomOrderFailsWhenBalanceInsufficient() throws Exception {
+ void randomOrderFailsWhenBalanceInsufficient__covers_CUS_008() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String remark = "API random insufficient " + IdUtils.getUuid();
try {
@@ -120,9 +125,10 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
@Test
// 测试用例:客户带随机下单请求命中默认等级与服务,接口应返回成功文案,
// 并新增一条处于待接单状态的随机订单,金额、商品信息与 remark 与提交参数保持一致。
- void randomOrderCreatesPendingOrder() throws Exception {
+ void randomOrderCreatesPendingOrder__covers_CUS_007() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
+ reset(wxCustomMpService, overdueOrderHandlerTask);
resetCustomerBalance();
String rawToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, rawToken);
@@ -175,6 +181,104 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
Assertions.assertThat(latest.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode());
Assertions.assertThat(latest.getCommodityId()).isEqualTo(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
Assertions.assertThat(latest.getOrderMoney()).isNotNull();
+
+ verify(wxCustomMpService).sendCreateOrderMessageBatch(
+ argThat(list -> list != null && list.stream()
+ .anyMatch(item -> ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID.equals(item.getOpenid()))),
+ eq(latest.getOrderNo()),
+ org.mockito.ArgumentMatchers.anyString(),
+ eq(latest.getCommodityName()),
+ eq(latest.getId()),
+ eq(latest.getPlaceType()),
+ eq(latest.getRewardType()));
+ verify(overdueOrderHandlerTask).enqueue(eq(latest.getId() + "_" + ApiTestDataSeeder.DEFAULT_TENANT_ID));
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ void endOrderTransitionsFromInProgressToCompleted__covers_CUS_010() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ String remark = "API random end " + IdUtils.getUuid();
+ String orderId = placeRandomOrder(remark, customerToken);
+
+ ensureTenantContext();
+ mockMvc.perform(get("/wx/clerk/order/accept")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ ensureTenantContext();
+ mockMvc.perform(get("/wx/clerk/order/start")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ ensureTenantContext();
+ mockMvc.perform(get("/wx/custom/order/end")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity updated = playOrderInfoService.selectOrderInfoById(orderId);
+ Assertions.assertThat(updated.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode());
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ void cancellationCreatesRefundRecordAndIgnoresImages__covers_CUS_011() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ String remark = "API random cancel record " + IdUtils.getUuid();
+ String orderId = placeRandomOrder(remark, customerToken);
+
+ String cancelPayload = "{\"orderId\":\"" + orderId + "\",\"refundReason\":\"测试取消\","
+ + "\"images\":[\"https://img.example/a.png\",\"https://img.example/b.png\"]}";
+ mockMvc.perform(post("/wx/custom/order/cancellation")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(cancelPayload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("取消成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(orderId);
+ Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
+ Assertions.assertThat(cancelled.getRefundReason()).isNull();
+
+ ensureTenantContext();
+ PlayOrderRefundInfoEntity refund = orderRefundInfoService.selectPlayOrderRefundInfoByOrderId(orderId);
+ Assertions.assertThat(refund).isNotNull();
+ Assertions.assertThat(refund.getRefundReason()).isEqualTo("测试取消");
+ Assertions.assertThat(refund.getImages()).isNull();
} finally {
CustomSecurityContextHolder.remove();
}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java
index b0635b7..5712482 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java
@@ -64,7 +64,7 @@ class WxCustomRewardOrderApiTest extends WxCustomOrderApiTestSupport {
@Test
// 测试用例:客户指定打赏金额下单时,应即时扣减账户余额、生成已完成的打赏订单并同步收益记录,
// 同时校验订单归属陪玩师正确且金额与输入一致,确保余额打赏流程闭环。
- void rewardOrderConsumesBalanceAndGeneratesEarnings() throws Exception {
+ void rewardOrderConsumesBalanceAndGeneratesEarnings__covers_CUS_004() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
resetCustomerBalance();
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java
index 02f00f5..cf60f21 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java
@@ -358,7 +358,7 @@ class WxCustomSpecifiedOrderApiTest extends WxCustomOrderApiTestSupport {
@Test
// 测试用例:客户携带指定陪玩师和服务下单时,接口需返回成功,并生成待支付状态的指定订单,
// 验证订单金额与种子服务价格一致、陪玩师被正确指派,同时触发微信创建订单通知。
- void specifiedOrderCreatesPendingOrder() throws Exception {
+ void specifiedOrderCreatesPendingOrder__covers_CUS_006() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
resetCustomerBalance();
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxOauthControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxOauthControllerApiTest.java
new file mode 100644
index 0000000..1d1a8c6
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxOauthControllerApiTest.java
@@ -0,0 +1,290 @@
+package com.starry.admin.api;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
+import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.redis.RedisCache;
+import me.chanjar.weixin.common.bean.WxJsapiSignature;
+import me.chanjar.weixin.common.bean.WxOAuth2UserInfo;
+import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
+import me.chanjar.weixin.common.service.WxOAuth2Service;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.bean.result.WxMpUser;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+
+class WxOauthControllerApiTest extends AbstractApiTest {
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayCustomUserInfoService customUserInfoService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private WxMpService wxMpService;
+
+ @Autowired
+ private RedisCache redisCache;
+
+ @Test
+ void getConfigAddressUsesDefaultWhenUrlMissing__covers_OAUTH_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ when(wxMpService.createJsapiSignature(anyString())).thenReturn(new WxJsapiSignature());
+
+ mockMvc.perform(post("/wx/oauth2/getConfigAddress")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"url\":\"\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+ }
+
+ @Test
+ void getConfigAddressUsesProvidedUrlWhenPresent__covers_OAUTH_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ when(wxMpService.createJsapiSignature(anyString())).thenReturn(new WxJsapiSignature());
+
+ mockMvc.perform(post("/wx/oauth2/getConfigAddress")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"url\":\"https://example.com/custom\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+ }
+
+ @Test
+ void getClerkLoginAddressBuildsAuthorizationUrl__covers_OAUTH_003() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ when(wxMpService.getOAuth2Service().buildAuthorizationUrl(anyString(), anyString(), anyString()))
+ .thenReturn("https://wx.example/auth?scope=snsapi_userinfo");
+
+ mockMvc.perform(post("/wx/oauth2/getClerkLoginAddress")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"url\":\"https://example.com/callback\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("https://wx.example/auth?scope=snsapi_userinfo"));
+ }
+
+ @Test
+ void getCustomLoginAddressBuildsAuthorizationUrl__covers_OAUTH_004() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ when(wxMpService.getOAuth2Service().buildAuthorizationUrl(anyString(), anyString(), anyString()))
+ .thenReturn("https://wx.example/auth?scope=snsapi_userinfo");
+
+ mockMvc.perform(post("/wx/oauth2/getCustomLoginAddress")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"url\":\"https://example.com/callback\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("https://wx.example/auth?scope=snsapi_userinfo"));
+ }
+
+ @Test
+ void customLoginPersistsTokenAndReturnsPayload__covers_OAUTH_005() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ WxOAuth2AccessToken token = new WxOAuth2AccessToken();
+ token.setOpenId(ApiTestDataSeeder.DEFAULT_CUSTOMER_OPEN_ID);
+ WxOAuth2Service oAuth2Service = wxMpService.getOAuth2Service();
+ Mockito.doReturn(token).when(oAuth2Service).getAccessToken(anyString());
+
+ WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
+ userInfo.setOpenid(ApiTestDataSeeder.DEFAULT_CUSTOMER_OPEN_ID);
+ userInfo.setNickname("API Test Customer");
+ userInfo.setHeadImgUrl("https://example.com/avatar.png");
+ Mockito.doReturn(userInfo).when(oAuth2Service).getUserInfo(eq(token), eq(null));
+
+ mockMvc.perform(post("/wx/oauth2/custom/login")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"code\":\"apitest-code\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.tokenValue").isString())
+ .andExpect(jsonPath("$.data.tokenName").value(Constants.CUSTOM_USER_LOGIN_TOKEN));
+
+ PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (customer == null || customer.getToken() == null || customer.getToken().isEmpty()) {
+ throw new AssertionError("Expected customer token to be persisted after login");
+ }
+ Object cachedTenantId = redisCache.getCacheObject("TENANT_INFO:" + ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (!ApiTestDataSeeder.DEFAULT_TENANT_ID.equals(cachedTenantId)) {
+ throw new AssertionError("Expected Redis TENANT_INFO to be cached after login");
+ }
+ }
+
+ @Test
+ void customLoginReturnsUnauthorizedWhenWxOauthFails__covers_OAUTH_006() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ WxOAuth2Service oAuth2Service = wxMpService.getOAuth2Service();
+ Mockito.doThrow(new RuntimeException("wx-fail")).when(oAuth2Service).getAccessToken(eq("fail-code"));
+
+ mockMvc.perform(post("/wx/oauth2/custom/login")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"code\":\"fail-code\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401));
+ }
+
+ @Test
+ void customLogoutInvalidatesToken__covers_OAUTH_008() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
+
+ mockMvc.perform(get("/wx/oauth2/custom/logout")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (customer == null) {
+ throw new AssertionError("Customer missing");
+ }
+ if (!"empty".equals(customer.getToken())) {
+ throw new AssertionError("Expected token to be invalidated to 'empty'");
+ }
+ }
+
+ @Test
+ void clerkLoginPersistsTokenAndCachesTenant__covers_OAUTH_007() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ String clerkId = "clerk-oauth-apitest";
+ String clerkOpenId = "openid-clerk-oauth-apitest";
+
+ PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
+ if (existing == null) {
+ PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
+ entity.setId(clerkId);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setOpenid(clerkOpenId);
+ entity.setNickname("API Test Clerk OAuth");
+ entity.setAvatar("https://example.com/avatar.png");
+ entity.setSysUserId("");
+ entity.setOnboardingState("1");
+ entity.setListingState("1");
+ entity.setClerkState("1");
+ entity.setOnlineState("1");
+ clerkUserInfoService.save(entity);
+ } else {
+ PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
+ patch.setId(clerkId);
+ patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ patch.setOpenid(clerkOpenId);
+ patch.setAvatar("https://example.com/avatar.png");
+ patch.setSysUserId("");
+ patch.setOnboardingState("1");
+ patch.setListingState("1");
+ patch.setClerkState("1");
+ patch.setOnlineState("1");
+ patch.setDeleted(Boolean.FALSE);
+ clerkUserInfoService.updateById(patch);
+ }
+
+ WxOAuth2AccessToken token = new WxOAuth2AccessToken();
+ token.setOpenId(clerkOpenId);
+ WxOAuth2Service oAuth2Service = wxMpService.getOAuth2Service();
+ Mockito.doReturn(token).when(oAuth2Service).getAccessToken(anyString());
+
+ WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
+ userInfo.setOpenid(clerkOpenId);
+ userInfo.setNickname("API Test Clerk");
+ userInfo.setHeadImgUrl("https://example.com/avatar.png");
+ Mockito.doReturn(userInfo).when(oAuth2Service).getUserInfo(eq(token), eq(null));
+
+ mockMvc.perform(post("/wx/oauth2/clerk/login")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"code\":\"apitest-code\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.tokenValue").isString())
+ .andExpect(jsonPath("$.data.tokenName").value(Constants.CLERK_USER_LOGIN_TOKEN))
+ .andExpect(jsonPath("$.data.pcData.token").value(""))
+ .andExpect(jsonPath("$.data.pcData.role").value(""));
+
+ PlayClerkUserInfoEntity clerk = clerkUserInfoService.selectById(clerkId);
+ if (clerk == null || clerk.getToken() == null || clerk.getToken().isEmpty() || "empty".equals(clerk.getToken())) {
+ throw new AssertionError("Expected clerk token to be persisted after login");
+ }
+ Object cachedTenantId = redisCache.getCacheObject("TENANT_INFO:" + clerkId);
+ if (!ApiTestDataSeeder.DEFAULT_TENANT_ID.equals(cachedTenantId)) {
+ throw new AssertionError("Expected clerk TENANT_INFO to be cached after login");
+ }
+ }
+
+ @Test
+ void clerkLogoutInvalidatesToken__covers_OAUTH_007() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, token);
+
+ mockMvc.perform(get("/wx/oauth2/clerk/logout")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ PlayClerkUserInfoEntity clerk = clerkUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ if (clerk == null) {
+ throw new AssertionError("Clerk missing");
+ }
+ if (!"empty".equals(clerk.getToken())) {
+ throw new AssertionError("Expected clerk token to be invalidated to 'empty'");
+ }
+ }
+
+ @Test
+ void checkSubscribeReturnsBoolean__covers_OAUTH_011() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
+
+ WxMpUser wxMpUser = new WxMpUser();
+ wxMpUser.setSubscribe(true);
+ when(wxMpService.getUserService().userInfo(ApiTestDataSeeder.DEFAULT_CUSTOMER_OPEN_ID)).thenReturn(wxMpUser);
+
+ mockMvc.perform(get("/wx/oauth2/checkSubscribe")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value(true));
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxOrderInfoControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxOrderInfoControllerApiTest.java
index 196f41d..6c2675b 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxOrderInfoControllerApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxOrderInfoControllerApiTest.java
@@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.common.task.OverdueOrderHandlerTask;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.order.module.constant.OrderConstant;
@@ -125,7 +126,7 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
}
@Test
- void duplicateContinuationRequestIsRejected() throws Exception {
+ void continueRejectsSecondContinuationRequest__covers_ORD_002() throws Exception {
String marker = "continue-" + LocalDateTime.now().toString();
String orderId = createRandomOrder(marker);
@@ -168,7 +169,36 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
}
@Test
- void randomOrderAcceptedByAnotherClerkHidesSensitiveFields() throws Exception {
+ void continueRejectsNonOwnerClerk__covers_ORD_001() throws Exception {
+ String marker = "continue-non-owner-" + LocalDateTime.now();
+ String orderId = createRandomOrder(marker);
+
+ ensureTenantContext();
+ playOrderInfoService.lambdaUpdate()
+ .eq(PlayOrderInfoEntity::getId, orderId)
+ .set(PlayOrderInfoEntity::getAcceptBy, OTHER_CLERK_ID)
+ .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode())
+ .update();
+
+ ArrayNode images = mapper.createArrayNode().add("https://example.com/proof.png");
+ ObjectNode payload = mapper.createObjectNode()
+ .put("orderId", orderId)
+ .put("remark", "加场申请")
+ .set("images", images);
+
+ mockMvc.perform(post("/wx/order/clerk/continue")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("非本人订单;无法续单"));
+ }
+
+ @Test
+ void randomOrderAcceptedByAnotherClerkHidesSensitiveFields__covers_ORD_003() throws Exception {
String marker = PRIVACY_MARKER_PREFIX + "accepted-" + LocalDateTime.now();
String orderId = createRandomOrder(marker);
@@ -197,6 +227,87 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
assertThat(data.path("customAvatar").asText()).isEmpty();
}
+ @Test
+ void updateReviewStateRejectsWhenAlreadyProcessed__covers_ORD_004() throws Exception {
+ ensureTenantContext();
+ PlayOrderContinueInfoEntity entity = new PlayOrderContinueInfoEntity();
+ entity.setId("cont-processed-" + java.util.UUID.randomUUID().toString().substring(0, 8));
+ entity.setOrderId("order-" + java.util.UUID.randomUUID().toString().substring(0, 8));
+ entity.setOrderNo("ORDER-" + java.util.UUID.randomUUID().toString().substring(0, 6));
+ entity.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ entity.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
+ entity.setOrderMoney(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+ entity.setFinalAmount(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+ entity.setContinueMsg("processed");
+ entity.setReviewedRequired("1");
+ entity.setReviewedState("1");
+ entity.setContinueTime(LocalDateTime.now().minusMinutes(1));
+ orderContinueInfoService.create(entity);
+
+ String payload = "{\"id\":\"" + entity.getId() + "\",\"reviewState\":\"1\",\"remark\":\"ok\"}";
+ mockMvc.perform(post("/wx/order/custom/updateReviewState")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("续单已处理"));
+ }
+
+ @Test
+ void continueListByPageForcesCustomIdToSession__covers_ORD_005() throws Exception {
+ ensureTenantContext();
+ String ownId = "cont-own-" + java.util.UUID.randomUUID().toString().substring(0, 8);
+ PlayOrderContinueInfoEntity own = new PlayOrderContinueInfoEntity();
+ own.setId(ownId);
+ own.setOrderId("order-own-" + java.util.UUID.randomUUID().toString().substring(0, 8));
+ own.setOrderNo("OWN-" + java.util.UUID.randomUUID().toString().substring(0, 6));
+ own.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ own.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ own.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
+ own.setOrderMoney(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+ own.setFinalAmount(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+ own.setReviewedRequired("1");
+ own.setReviewedState("0");
+ own.setContinueTime(LocalDateTime.now().minusMinutes(1));
+ orderContinueInfoService.create(own);
+
+ String otherId = "cont-other-" + java.util.UUID.randomUUID().toString().substring(0, 8);
+ PlayOrderContinueInfoEntity other = new PlayOrderContinueInfoEntity();
+ other.setId(otherId);
+ other.setOrderId("order-other-" + java.util.UUID.randomUUID().toString().substring(0, 8));
+ other.setOrderNo("OTHER-" + java.util.UUID.randomUUID().toString().substring(0, 6));
+ other.setCustomId("other-customer");
+ other.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ other.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
+ other.setOrderMoney(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+ other.setFinalAmount(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+ other.setReviewedRequired("1");
+ other.setReviewedState("0");
+ other.setContinueTime(LocalDateTime.now().minusMinutes(1));
+ orderContinueInfoService.create(other);
+
+ String payload = "{\"pageNum\":1,\"pageSize\":20,\"customId\":\"other-customer\"}";
+ MvcResult result = mockMvc.perform(post("/wx/order/custom/continueListByPage")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode records = mapper.readTree(result.getResponse().getContentAsString()).path("data");
+ assertThat(records.isArray()).isTrue();
+ assertThat(records)
+ .anyMatch(node -> ownId.equals(node.path("id").asText()))
+ .noneMatch(node -> otherId.equals(node.path("id").asText()));
+ }
+
@ParameterizedTest
@MethodSource("randomOrderMaskingCases")
void selectRandomOrderByIdAppliesPrivacyRules(OrderStatus status,
@@ -334,7 +445,7 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
}
@Test
- void queryByIdForRandomOrderAcceptedByAnotherClerkHidesCustomerInfoAndOrderStatus() throws Exception {
+ void queryByIdForRandomOrderAcceptedByAnotherClerkHidesCustomerInfoAndOrderStatus__covers_CLK_013() throws Exception {
String marker = "random-other-clerk-" + LocalDateTime.now();
String orderId = createRandomOrder(marker);
@@ -366,6 +477,103 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
assertThat(data.path("customAvatar").asText()).isEmpty();
}
+ @Test
+ void queryByIdForCancelledOrderClearsWeiChatCode__covers_CLK_014() throws Exception {
+ String marker = "cancelled-weiChatCode-" + LocalDateTime.now();
+ String orderId = createRandomOrder(marker);
+
+ ensureTenantContext();
+ playOrderInfoService.lambdaUpdate()
+ .eq(PlayOrderInfoEntity::getId, orderId)
+ .set(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.SPECIFIED.getCode())
+ .set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.CANCELLED.getCode())
+ .update();
+
+ MvcResult result = mockMvc.perform(get("/wx/clerk/order/queryById")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode data = mapper.readTree(result.getResponse().getContentAsString()).path("data");
+ assertThat(data.path("orderStatus").asText()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
+ assertThat(data.path("weiChatCode").asText()).isEmpty();
+ }
+
+ @Test
+ void acceptOrderRequiresSubscribe__covers_CLK_015() throws Exception {
+ String orderId = createRandomOrder("accept-subscribe-" + LocalDateTime.now());
+
+ Mockito.doThrow(new ServiceException("请先关注公众号然后再来使用系统~"))
+ .when(wxCustomMpService)
+ .checkSubscribeThrowsExp(Mockito.anyString(), Mockito.anyString());
+
+ mockMvc.perform(get("/wx/clerk/order/accept")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请先关注公众号然后再来使用系统~"));
+ }
+
+ @Test
+ void startOrderTransitionsState__covers_CLK_016() throws Exception {
+ String orderId = createRandomOrder("start-" + LocalDateTime.now());
+
+ ensureTenantContext();
+ playOrderInfoService.lambdaUpdate()
+ .eq(PlayOrderInfoEntity::getId, orderId)
+ .set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode())
+ .update();
+
+ mockMvc.perform(get("/wx/clerk/order/start")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity updated = playOrderInfoService.selectOrderInfoById(orderId);
+ assertThat(updated.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.IN_PROGRESS.getCode());
+ }
+
+ @Test
+ void clerkCancellationUpdatesOrderState__covers_CLK_019() throws Exception {
+ String orderId = createRandomOrder("clerk-cancel-" + LocalDateTime.now());
+
+ ensureTenantContext();
+ playOrderInfoService.lambdaUpdate()
+ .eq(PlayOrderInfoEntity::getId, orderId)
+ .set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode())
+ .update();
+
+ String payload = "{\"orderId\":\"" + orderId + "\",\"refundReason\":\"clerk cancel\",\"images\":[\"https://img.example/1.png\"]}";
+ mockMvc.perform(post("/wx/clerk/order/cancellation")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(orderId);
+ assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
+ }
+
@Test
void queryByIdForNonRandomOrderAcceptedByAnotherClerkDoesNotMaskStatus() throws Exception {
String marker = "specified-other-clerk-" + LocalDateTime.now();
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxPayControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxPayControllerApiTest.java
new file mode 100644
index 0000000..364ebb8
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxPayControllerApiTest.java
@@ -0,0 +1,494 @@
+package com.starry.admin.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.binarywang.wxpay.bean.profitsharing.ProfitSharingResult;
+import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest;
+import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderResult;
+import com.github.binarywang.wxpay.service.WxPayService;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.common.exception.ServiceException;
+import com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
+import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService;
+import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
+import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
+import com.starry.admin.modules.order.service.IPlayOrderInfoService;
+import com.starry.admin.modules.system.module.entity.SysTenantEntity;
+import com.starry.admin.modules.system.service.impl.SysTenantServiceImpl;
+import com.starry.admin.modules.weichat.service.WxCustomMpService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import java.math.BigDecimal;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxPayControllerApiTest extends AbstractApiTest {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayCustomUserInfoService customUserInfoService;
+
+ @Autowired
+ private IPlayCustomLevelInfoService customLevelInfoService;
+
+ @Autowired
+ private IPlayOrderInfoService orderInfoService;
+
+ @Autowired
+ private SysTenantServiceImpl tenantService;
+
+ @MockBean
+ private WxCustomMpService wxCustomMpService;
+
+ @BeforeEach
+ void setUp() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ }
+
+ @Test
+ void getCustomPaymentAmountRejectsEmptyMoney__covers_PAY_001() throws Exception {
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
+
+ mockMvc.perform(get("/wx/pay/custom/getCustomPaymentAmount")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .param("money", ""))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500));
+ }
+
+ @Test
+ void getCustomPaymentAmountReturnsDiscountedAmount__covers_PAY_002() throws Exception {
+ String levelId = "lvl-custom-apitest";
+ PlayCustomLevelInfoEntity level = customLevelInfoService.getById(levelId);
+ if (level == null) {
+ level = new PlayCustomLevelInfoEntity();
+ level.setId(levelId);
+ level.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ level.setName("API Test Level");
+ level.setLevel(1);
+ level.setConsumptionAmount("0");
+ level.setDiscount(80);
+ customLevelInfoService.save(level);
+ } else {
+ level.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ level.setDiscount(80);
+ customLevelInfoService.updateById(level);
+ }
+ customUserInfoService.lambdaUpdate()
+ .set(PlayCustomUserInfoEntity::getLevelId, levelId)
+ .eq(PlayCustomUserInfoEntity::getId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .update();
+
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
+
+ MvcResult result = mockMvc.perform(get("/wx/pay/custom/getCustomPaymentAmount")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .param("money", "10.00"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode root = OBJECT_MAPPER.readTree(result.getResponse().getContentAsString());
+ BigDecimal actual = root.get("data").decimalValue();
+ BigDecimal expected = new BigDecimal("0.80").multiply(new BigDecimal("10.00"));
+ if (actual.compareTo(expected) != 0) {
+ throw new AssertionError("Expected discounted amount=" + expected + " but got " + actual);
+ }
+ }
+
+ @Test
+ void createOrderCreatesRechargeOrderAndReturnsPayParams__covers_PAY_004() throws Exception {
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
+
+ WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ WxPayUnifiedOrderResult result = new WxPayUnifiedOrderResult();
+ result.setPrepayId("prepay-123");
+ when(wxPayService.unifiedOrder(any())).thenReturn(result);
+ when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
+ org.mockito.Mockito.doNothing().when(wxCustomMpService).checkSubscribeThrowsExp(anyString(), anyString());
+ when(wxPayService.getConfig().getAppId()).thenReturn("wx-app");
+ when(wxPayService.getConfig().getMchKey()).thenReturn("mch-key");
+ when(wxPayService.getConfig().getNotifyUrl()).thenReturn("https://example.com/notify");
+
+ mockMvc.perform(get("/wx/pay/custom/createOrder")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .param("money", "12.34"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.appId").value("wx-app"))
+ .andExpect(jsonPath("$.data.package").value(org.hamcrest.Matchers.startsWith("prepay_id=")))
+ .andExpect(jsonPath("$.data.signType").value("MD5"))
+ .andExpect(jsonPath("$.data.paySign").isString());
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(WxPayUnifiedOrderRequest.class);
+ verify(wxPayService).unifiedOrder(requestCaptor.capture());
+ String outTradeNo = requestCaptor.getValue().getOutTradeNo();
+
+ PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(outTradeNo);
+ assertThat(order).as("recharge order should be created, outTradeNo=%s", outTradeNo).isNotNull();
+ assertThat(order.getPurchaserBy()).isEqualTo(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ assertThat(order.getOrderType()).isEqualTo("0");
+ assertThat(order.getPayState()).as("pay_state should be pending before callback").isEqualTo("0");
+ }
+
+ @Test
+ void createOrderRejectsWhenUserNotSubscribed__covers_PAY_005() throws Exception {
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
+
+ org.mockito.Mockito.doThrow(new ServiceException("请先关注公众号然后再来使用系统~"))
+ .when(wxCustomMpService)
+ .checkSubscribeThrowsExp(anyString(), anyString());
+
+ mockMvc.perform(get("/wx/pay/custom/createOrder")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .param("money", "10.00"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请先关注公众号然后再来使用系统~"));
+
+ verify(wxCustomMpService, never()).getWxPay();
+ }
+
+ @Test
+ void wxPayCallbackUpdatesPayStateAndBalanceAndIsIdempotent__covers_PAY_009__covers_PAY_010() throws Exception {
+ PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (customer == null) {
+ throw new AssertionError("Missing seeded customer");
+ }
+ BigDecimal before = customer.getAccountBalance();
+
+ // Create a recharge order.
+ String orderNo = orderInfoService.getOrderNo();
+ orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
+ ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+
+ WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
+ when(wxPayService.getProfitSharingService().profitSharing(any())).thenReturn(null);
+ org.mockito.Mockito.doNothing().when(wxCustomMpService).sendBalanceMessage(any());
+
+ String xml = ""
+ + "" + orderNo + ""
+ + "" + ApiTestDataSeeder.DEFAULT_TENANT_ID + ""
+ + "tx-1"
+ + "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
+ if (order == null) {
+ throw new AssertionError("Order not found");
+ }
+ if (!"1".equals(order.getPayState())) {
+ throw new AssertionError("Expected pay_state=1 after callback");
+ }
+
+ PlayCustomUserInfoEntity afterFirst = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (afterFirst == null) {
+ throw new AssertionError("Missing customer after callback");
+ }
+ if (afterFirst.getAccountBalance().compareTo(before) <= 0) {
+ throw new AssertionError("Expected account balance increased after callback");
+ }
+
+ // replay same callback should not double add
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ PlayCustomUserInfoEntity afterSecond = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (afterSecond == null) {
+ throw new AssertionError("Missing customer after replay");
+ }
+ if (afterSecond.getAccountBalance().compareTo(afterFirst.getAccountBalance()) != 0) {
+ throw new AssertionError("Expected callback replay to be idempotent for balance");
+ }
+ }
+
+ @Test
+ void wxPayCallbackSkipsProfitSharingWhenComputedAmountIsZero__covers_PAY_012() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ SysTenantEntity tenant = tenantService.selectSysTenantByTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ if (tenant == null) {
+ throw new AssertionError("Missing seeded tenant");
+ }
+ Integer originalRate = tenant.getProfitsharingRate();
+ try {
+ tenantService.lambdaUpdate()
+ .eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .set(SysTenantEntity::getProfitsharingRate, 1)
+ .update();
+
+ String orderNo = orderInfoService.getOrderNo();
+ // 1 cent recharge with 1% profit sharing => amount=0 (int truncation)
+ orderInfoService.createRechargeOrder(orderNo, new BigDecimal("0.01"), new BigDecimal("0.01"),
+ ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+
+ WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
+ org.mockito.Mockito.doNothing().when(wxCustomMpService).sendBalanceMessage(any());
+
+ String xml = ""
+ + "" + orderNo + ""
+ + "" + ApiTestDataSeeder.DEFAULT_TENANT_ID + ""
+ + "tx-ps-0"
+ + "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ verify(wxPayService.getProfitSharingService(), never()).profitSharing(any());
+
+ PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
+ if (order == null) {
+ throw new AssertionError("Order not found");
+ }
+ if (order.getProfitSharingAmount() != null) {
+ throw new AssertionError("Expected profit_sharing_amount to remain null when amount=0");
+ }
+ } finally {
+ tenantService.lambdaUpdate()
+ .eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .set(SysTenantEntity::getProfitsharingRate, originalRate == null ? 0 : originalRate)
+ .update();
+ }
+ }
+
+ @Test
+ void wxPayCallbackWritesProfitSharingAmountWhenRatePositive__covers_PAY_013() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ SysTenantEntity tenant = tenantService.selectSysTenantByTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ if (tenant == null) {
+ throw new AssertionError("Missing seeded tenant");
+ }
+ Integer originalRate = tenant.getProfitsharingRate();
+ try {
+ tenantService.lambdaUpdate()
+ .eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .set(SysTenantEntity::getProfitsharingRate, 1)
+ .update();
+
+ String orderNo = orderInfoService.getOrderNo();
+ orderInfoService.createRechargeOrder(orderNo, new BigDecimal("1.00"), new BigDecimal("1.00"),
+ ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+
+ WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
+ when(wxPayService.getProfitSharingService().profitSharing(any())).thenReturn(new ProfitSharingResult());
+ org.mockito.Mockito.doNothing().when(wxCustomMpService).sendBalanceMessage(any());
+
+ String xml = ""
+ + "" + orderNo + ""
+ + "" + ApiTestDataSeeder.DEFAULT_TENANT_ID + ""
+ + "tx-ps-1"
+ + "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ verify(wxPayService.getProfitSharingService()).profitSharing(any());
+
+ PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
+ if (order == null) {
+ throw new AssertionError("Order not found");
+ }
+ if (order.getProfitSharingAmount() == null) {
+ throw new AssertionError("Expected profit_sharing_amount to be set");
+ }
+ if (order.getProfitSharingAmount().compareTo(new BigDecimal("0.01")) != 0) {
+ throw new AssertionError("Expected profit_sharing_amount=0.01, got: " + order.getProfitSharingAmount());
+ }
+ } finally {
+ tenantService.lambdaUpdate()
+ .eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .set(SysTenantEntity::getProfitsharingRate, originalRate == null ? 0 : originalRate)
+ .update();
+ }
+ }
+
+ @Test
+ void wxPayCallbackWithInvalidXmlStillReturnsSuccessAndNoStateChange__covers_PAY_006() throws Exception {
+ String xml = "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ void wxPayCallbackUnknownOrderDoesNotChangeState__covers_PAY_007() throws Exception {
+ String orderNo = "unknown-order-no-apitest";
+ String xml = ""
+ + "" + orderNo + ""
+ + "" + ApiTestDataSeeder.DEFAULT_TENANT_ID + ""
+ + "tx-unknown"
+ + "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
+ if (order != null) {
+ throw new AssertionError("Did not expect an order to be created for unknown out_trade_no");
+ }
+ }
+
+ @Test
+ void wxPayCallbackDoesNotReprocessAlreadyPaidRechargeOrder__covers_PAY_008() throws Exception {
+ PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (customer == null) {
+ throw new AssertionError("Missing seeded customer");
+ }
+ BigDecimal before = customer.getAccountBalance();
+
+ String orderNo = orderInfoService.getOrderNo();
+ orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
+ ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
+ if (order == null) {
+ throw new AssertionError("Expected order to exist");
+ }
+ order.setPayState("1");
+ orderInfoService.updateById(order);
+
+ String xml = ""
+ + "" + orderNo + ""
+ + "" + ApiTestDataSeeder.DEFAULT_TENANT_ID + ""
+ + "tx-repeat"
+ + "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ PlayCustomUserInfoEntity after = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ if (after == null) {
+ throw new AssertionError("Missing customer after callback");
+ }
+ if (after.getAccountBalance().compareTo(before) != 0) {
+ throw new AssertionError("Expected no balance change for already paid order");
+ }
+ }
+
+ @Test
+ void wxPayCallbackDoesNotProcessNonRechargeOrderType__covers_PAY_008() throws Exception {
+ String orderNo = orderInfoService.getOrderNo();
+ orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
+ ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+
+ PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
+ if (order == null) {
+ throw new AssertionError("Expected order to exist");
+ }
+ order.setOrderType("1");
+ order.setPayState("0");
+ orderInfoService.updateById(order);
+
+ String xml = ""
+ + "" + orderNo + ""
+ + "" + ApiTestDataSeeder.DEFAULT_TENANT_ID + ""
+ + "tx-nonrecharge"
+ + "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ PlayOrderInfoEntity after = orderInfoService.queryByOrderNo(orderNo);
+ if (after == null) {
+ throw new AssertionError("Order missing after callback");
+ }
+ if (!"0".equals(after.getPayState())) {
+ throw new AssertionError("Expected pay_state unchanged for non-recharge order");
+ }
+ }
+
+ @Test
+ void wxPayCallbackMissingAttachDoesNotUpdateOrder__covers_PAY_011() throws Exception {
+ String orderNo = orderInfoService.getOrderNo();
+ orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
+ ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+
+ String xml = ""
+ + "" + orderNo + ""
+ + "tx-missing-attach"
+ + "";
+
+ mockMvc.perform(post("/wx/pay/jsCallback")
+ .contentType(MediaType.TEXT_XML)
+ .content(xml))
+ .andExpect(status().isOk());
+
+ PlayOrderInfoEntity after = orderInfoService.queryByOrderNo(orderNo);
+ if (after == null) {
+ throw new AssertionError("Order missing after callback");
+ }
+ if (!"0".equals(after.getPayState())) {
+ throw new AssertionError("Expected pay_state unchanged when attach is missing");
+ }
+ }
+
+ @Test
+ void createOrderRejectsMoneyBelowOne__covers_PAY_003() throws Exception {
+ String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
+
+ mockMvc.perform(get("/wx/pay/custom/createOrder")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
+ .param("money", "0.99"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500));
+
+ verify(wxCustomMpService, never()).getWxPay();
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxShopControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxShopControllerApiTest.java
new file mode 100644
index 0000000..c9f8970
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxShopControllerApiTest.java
@@ -0,0 +1,139 @@
+package com.starry.admin.api;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.shop.module.entity.PlayShopArticleInfoEntity;
+import com.starry.admin.modules.shop.module.entity.PlayShopCarouselInfoEntity;
+import com.starry.admin.modules.shop.service.IPlayShopArticleInfoService;
+import com.starry.admin.modules.shop.service.IPlayShopCarouselInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.utils.IdUtils;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+
+class WxShopControllerApiTest extends AbstractApiTest {
+
+ @Autowired
+ private IPlayShopCarouselInfoService carouselInfoService;
+
+ @Autowired
+ private IPlayShopArticleInfoService articleInfoService;
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ private final java.util.List carouselIdsToCleanup = new java.util.ArrayList<>();
+ private final java.util.List articleIdsToCleanup = new java.util.ArrayList<>();
+ private String clerkToken;
+
+ @BeforeEach
+ void setUp() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ if (!carouselIdsToCleanup.isEmpty()) {
+ carouselInfoService.removeByIds(carouselIdsToCleanup);
+ carouselIdsToCleanup.clear();
+ }
+ if (!articleIdsToCleanup.isEmpty()) {
+ articleInfoService.removeByIds(articleIdsToCleanup);
+ articleIdsToCleanup.clear();
+ }
+ }
+
+ @Test
+ void getShopHomeCarouseInfoReturnsEnabledIndexZeroOnly__covers_SHOP_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String urlA = "https://example.com/carousel-a.png";
+ String urlB = "https://example.com/carousel-b.png";
+ insertCarousel("1", "0", urlA);
+ insertCarousel("1", "0", urlB);
+ insertCarousel("0", "0", "https://example.com/carousel-disabled.png");
+ insertCarousel("1", "1", "https://example.com/carousel-nonhome.png");
+
+ mockMvc.perform(get("/wx/shop/custom/getShopHomeCarouseInfo")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.length()").value(2))
+ .andExpect(jsonPath("$.data[*].carouselUrl").value(org.hamcrest.Matchers.hasItems(urlA, urlB)));
+ }
+
+ @Test
+ void readShopArticleInfoIncrementsVisitsNumber__covers_SHOP_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String articleId = insertShopArticle(0);
+
+ mockMvc.perform(get("/wx/shop/clerk/readShopArticleInfo")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"" + articleId + "\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayShopArticleInfoEntity stored = articleInfoService.selectById(articleId);
+ if (stored == null) {
+ throw new AssertionError("Expected shop article to exist");
+ }
+ if (stored.getVisitsNumber() == null || stored.getVisitsNumber() != 1) {
+ throw new AssertionError("Expected visitsNumber=1, got " + stored.getVisitsNumber());
+ }
+ }
+
+ private String insertCarousel(String enableState, String carouselIndex, String url) {
+ PlayShopCarouselInfoEntity entity = new PlayShopCarouselInfoEntity();
+ String id = "apitest-carousel-" + IdUtils.getUuid();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setCarouselIndex(carouselIndex);
+ entity.setEnableState(enableState);
+ entity.setCarouselUrl(url);
+ entity.setDeleted(false);
+ carouselInfoService.save(entity);
+ carouselIdsToCleanup.add(id);
+ return id;
+ }
+
+ private String insertShopArticle(int visitsNumber) {
+ PlayShopArticleInfoEntity entity = new PlayShopArticleInfoEntity();
+ String id = "apitest-article-" + IdUtils.getUuid();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setArticleType("apitest");
+ entity.setArticleTitle("API Test Shop Article");
+ entity.setArticleContent("content");
+ entity.setVisitsNumber(visitsNumber);
+ entity.setDeleted(false);
+ Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
+ entity.setCreatedTime(now);
+ entity.setUpdatedTime(now);
+ articleInfoService.save(entity);
+ articleIdsToCleanup.add(id);
+ return id;
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java
index f239cd4..e880cbf 100644
--- a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java
@@ -121,7 +121,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
}
@Test
- void balanceEndpointAggregatesAvailableAndPendingEarnings() throws Exception {
+ void balanceEndpointAggregatesAvailableAndPendingEarnings__covers_WD_001() throws Exception {
ensureTenantContext();
LocalDateTime now = LocalDateTime.now().withNano(0);
String availableId = insertEarningsLine(
@@ -154,7 +154,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
}
@Test
- void createWithdrawRejectsNonPositiveAmount() throws Exception {
+ void createWithdrawRejectsNonPositiveAmount__covers_WD_003() throws Exception {
ensureTenantContext();
mockMvc.perform(post("/wx/withdraw/requests")
.header(USER_HEADER, DEFAULT_USER)
@@ -168,7 +168,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
}
@Test
- void createWithdrawLocksEligibleEarningsLines() throws Exception {
+ void createWithdrawLocksEligibleEarningsLines__covers_WD_004() throws Exception {
ensureTenantContext();
BigDecimal amount = new BigDecimal("80.00");
String firstLine = insertEarningsLine(
@@ -217,7 +217,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
}
@Test
- void createWithdrawHandlesMixedPositiveAndNegativeLines() throws Exception {
+ void createWithdrawHandlesMixedPositiveAndNegativeLines__covers_WD_004() throws Exception {
ensureTenantContext();
LocalDateTime base = LocalDateTime.now().minusHours(4);
BigDecimal[] amounts = {
@@ -263,7 +263,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
}
@Test
- void earningsEndpointFiltersByStatus() throws Exception {
+ void earningsEndpointFiltersByStatus__covers_WD_002() throws Exception {
ensureTenantContext();
String availableId = insertEarningsLine(
"earning-available",
@@ -300,7 +300,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
}
@Test
- void concurrentWithdrawRequestsCompeteForSameEarningsLines() throws Exception {
+ void concurrentWithdrawRequestsCompeteForSameEarningsLines__covers_WD_004() throws Exception {
ensureTenantContext();
String firstLine = insertEarningsLine(
"concurrent-one",
@@ -345,6 +345,76 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
}
}
+ @Test
+ void earningsEndpointSupportsMultipleDateFormats__covers_WD_002() throws Exception {
+ ensureTenantContext();
+ LocalDateTime base = LocalDateTime.now().withNano(0).withHour(10).withMinute(0).withSecond(0);
+
+ String early = insertEarningsLineWithCreatedTime(
+ "time-early",
+ new BigDecimal("10.00"),
+ EarningsStatus.AVAILABLE,
+ base.minusDays(2));
+ String middle = insertEarningsLineWithCreatedTime(
+ "time-middle",
+ new BigDecimal("20.00"),
+ EarningsStatus.AVAILABLE,
+ base.minusDays(1));
+ String late = insertEarningsLineWithCreatedTime(
+ "time-late",
+ new BigDecimal("30.00"),
+ EarningsStatus.AVAILABLE,
+ base);
+ earningsToCleanup.add(early);
+ earningsToCleanup.add(middle);
+ earningsToCleanup.add(late);
+
+ String beginIso = base.minusDays(1).format(java.time.format.DateTimeFormatter.ISO_DATE_TIME);
+ String end12Hour = base.plusDays(1).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss"));
+
+ mockMvc.perform(get("/wx/withdraw/earnings")
+ .param("pageNum", "1")
+ .param("pageSize", "10")
+ .param("beginTime", beginIso)
+ .param("endTime", end12Hour)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").isArray())
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItems(middle, late)))
+ .andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(early))));
+ }
+
+ @Test
+ void requestLogsRejectsWhenNotOwner__covers_WD_005() throws Exception {
+ ensureTenantContext();
+ WithdrawalRequestEntity req = new WithdrawalRequestEntity();
+ String requestId = "apitest-withdraw-" + IdUtils.getUuid();
+ req.setId(requestId);
+ req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ req.setAmount(new BigDecimal("10.00"));
+ req.setFee(BigDecimal.ZERO);
+ req.setNetAmount(new BigDecimal("10.00"));
+ req.setStatus("pending");
+ withdrawalService.save(req);
+ withdrawalsToCleanup.add(requestId);
+
+ String otherClerkId = "apitest-clerk-other-" + IdUtils.getUuid().substring(0, 6);
+ String otherToken = wxTokenService.createWxUserToken(otherClerkId);
+ ensureActiveClerk(otherClerkId, "openid-" + otherClerkId, otherToken);
+
+ mockMvc.perform(get("/wx/withdraw/requests/" + requestId + "/logs")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + otherToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("无权查看"));
+ }
+
private String insertEarningsLine(
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);
@@ -376,6 +446,72 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
return id;
}
+ private String insertEarningsLineWithCreatedTime(
+ String suffix,
+ BigDecimal amount,
+ EarningsStatus status,
+ LocalDateTime createdAt) {
+ EarningsLineEntity entity = new EarningsLineEntity();
+ String id = "earn-" + suffix + "-" + IdUtils.getUuid();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ String rawOrderId = ORDER_ID_PREFIX + IdUtils.getUuid();
+ entity.setOrderId(rawOrderId.length() <= 32 ? rawOrderId : rawOrderId.substring(0, 32));
+ entity.setAmount(amount);
+ entity.setStatus(status.getCode());
+ entity.setUnlockTime(createdAt.plusHours(1));
+ Date stamp = toDate(createdAt);
+ entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
+ entity.setCreatedTime(toDate(LocalDateTime.now()));
+ entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
+ entity.setUpdatedTime(toDate(LocalDateTime.now()));
+ earningsService.save(entity);
+ earningsService.lambdaUpdate()
+ .set(EarningsLineEntity::getCreatedTime, stamp)
+ .set(EarningsLineEntity::getUpdatedTime, stamp)
+ .eq(EarningsLineEntity::getId, id)
+ .update();
+ return id;
+ }
+
+ private void ensureActiveClerk(String clerkId, String openId, String token) {
+ ensureTenantContext();
+ com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
+ if (existing == null) {
+ com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity entity =
+ new com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity();
+ entity.setId(clerkId);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setOpenid(openId);
+ entity.setNickname("API Test Clerk");
+ entity.setAvatar("https://example.com/avatar.png");
+ entity.setSysUserId("");
+ entity.setOnboardingState("1");
+ entity.setListingState("1");
+ entity.setClerkState("1");
+ entity.setOnlineState("1");
+ entity.setToken(token);
+ clerkUserInfoService.save(entity);
+ return;
+ }
+ com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity patch =
+ new com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity();
+ patch.setId(clerkId);
+ patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ patch.setOpenid(openId);
+ patch.setNickname(existing.getNickname());
+ patch.setAvatar(existing.getAvatar());
+ patch.setSysUserId("");
+ patch.setOnboardingState("1");
+ patch.setListingState("1");
+ patch.setClerkState("1");
+ patch.setOnlineState("1");
+ patch.setDeleted(Boolean.FALSE);
+ patch.setToken(token);
+ clerkUserInfoService.updateById(patch);
+ }
+
private void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawPayeeControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawPayeeControllerApiTest.java
new file mode 100644
index 0000000..ad91974
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawPayeeControllerApiTest.java
@@ -0,0 +1,173 @@
+package com.starry.admin.api;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.jayway.jsonpath.JsonPath;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
+import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.utils.IdUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxWithdrawPayeeControllerApiTest extends AbstractApiTest {
+
+ @Autowired
+ private WxTokenService wxTokenService;
+
+ @Autowired
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ private IClerkPayeeProfileService payeeProfileService;
+
+ private String clerkId;
+ private String clerkToken;
+
+ @BeforeEach
+ void setUp() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ clerkId = "clerk-payee-" + IdUtils.getUuid();
+ String clerkOpenId = "openid-payee-" + IdUtils.getUuid();
+ clerkToken = wxTokenService.createWxUserToken(clerkId);
+ String phone = "139" + String.format("%08d", java.util.concurrent.ThreadLocalRandom.current().nextInt(0, 100000000));
+
+ PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
+ clerk.setId(clerkId);
+ clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ clerk.setSysUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
+ clerk.setOpenid(clerkOpenId);
+ clerk.setNickname("payee-test");
+ clerk.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
+ clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
+ clerk.setFixingLevel("1");
+ clerk.setSex("2");
+ clerk.setPhone(phone);
+ clerk.setWeiChatCode("payee-test-" + IdUtils.getUuid());
+ clerk.setAvatar("https://example.com/avatar.png");
+ clerk.setAccountBalance(java.math.BigDecimal.ZERO);
+ clerk.setOnboardingState("1");
+ clerk.setListingState("1");
+ clerk.setDisplayState("1");
+ clerk.setOnlineState("1");
+ clerk.setRandomOrderState("1");
+ clerk.setClerkState("1");
+ clerk.setEntryTime(java.time.LocalDateTime.now());
+ clerk.setToken(clerkToken);
+ clerkUserInfoService.save(clerk);
+
+ payeeProfileService.lambdaUpdate()
+ .eq(ClerkPayeeProfileEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .eq(ClerkPayeeProfileEntity::getClerkId, clerkId)
+ .remove();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ payeeProfileService.lambdaUpdate()
+ .eq(ClerkPayeeProfileEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .eq(ClerkPayeeProfileEntity::getClerkId, clerkId)
+ .remove();
+
+ clerkUserInfoService.lambdaUpdate()
+ .eq(PlayClerkUserInfoEntity::getId, clerkId)
+ .remove();
+ }
+
+ @Test
+ void getProfileReturnsNullWhenAbsent__covers_PAYEE_001() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(get("/wx/withdraw/payee")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").isEmpty());
+ }
+
+ @Test
+ void upsertRejectsMissingQrCodeUrl__covers_PAYEE_002() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(post("/wx/withdraw/payee")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"qrCodeUrl\":\"\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请上传支付宝收款二维码"));
+ }
+
+ @Test
+ void upsertDefaultsChannelAndDisplayNameAndClearsConfirmation__covers_PAYEE_003() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ PlayClerkUserInfoEntity clerk = clerkUserInfoService.selectById(clerkId);
+ String expectedDisplayName = clerk != null ? clerk.getNickname() : null;
+
+ MvcResult result = mockMvc.perform(post("/wx/withdraw/payee")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"channel\":\"\",\"qrCodeUrl\":\"https://example.com/payee.png\",\"displayName\":\"\"}"))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String responseBody = result.getResponse().getContentAsString();
+ Integer businessCode = JsonPath.read(responseBody, "$.code");
+ Assertions.assertEquals(200, businessCode, "Unexpected response: " + responseBody);
+ Assertions.assertEquals("ALIPAY_QR", JsonPath.read(responseBody, "$.data.channel"));
+ Assertions.assertEquals("https://example.com/payee.png", JsonPath.read(responseBody, "$.data.qrCodeUrl"));
+
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ ClerkPayeeProfileEntity stored = payeeProfileService.getByClerk(
+ ApiTestDataSeeder.DEFAULT_TENANT_ID, clerkId);
+ if (stored == null) {
+ throw new AssertionError("Expected payee profile to be stored");
+ }
+ if (!"ALIPAY_QR".equals(stored.getChannel())) {
+ throw new AssertionError("Expected default channel ALIPAY_QR");
+ }
+ if (expectedDisplayName != null && !expectedDisplayName.equals(stored.getDisplayName())) {
+ throw new AssertionError("Expected displayName default to clerk nickname");
+ }
+ if (stored.getLastConfirmedAt() != null) {
+ throw new AssertionError("Expected lastConfirmedAt to be cleared after upsert");
+ }
+ }
+
+ @Test
+ void confirmRejectsWhenNoQrCodeUploaded__covers_PAYEE_004() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ mockMvc.perform(post("/wx/withdraw/payee/confirm")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"trace\":\"" + IdUtils.getUuid() + "\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("请先上传支付宝收款二维码"));
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/common/security/filter/JwtAuthenticationTokenFilterTest.java b/play-admin/src/test/java/com/starry/admin/common/security/filter/JwtAuthenticationTokenFilterTest.java
new file mode 100644
index 0000000..49f8485
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/common/security/filter/JwtAuthenticationTokenFilterTest.java
@@ -0,0 +1,193 @@
+package com.starry.admin.common.security.filter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.starry.admin.common.component.JwtToken;
+import com.starry.admin.common.exception.CustomException;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
+import com.starry.admin.modules.custom.service.impl.PlayCustomUserInfoServiceImpl;
+import com.starry.admin.modules.system.module.entity.SysTenantEntity;
+import com.starry.admin.modules.system.service.ISysTenantService;
+import com.starry.admin.modules.system.service.SysUserService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.common.redis.RedisCache;
+import java.io.IOException;
+import java.util.Date;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.servlet.HandlerExceptionResolver;
+
+@ExtendWith(MockitoExtension.class)
+class JwtAuthenticationTokenFilterTest {
+
+ @Mock
+ private WxTokenService tokenService;
+
+ @Mock
+ private JwtToken jwtToken;
+
+ @Mock
+ private HandlerExceptionResolver resolver;
+
+ @Mock
+ private PlayCustomUserInfoServiceImpl customUserInfoService;
+
+ @Mock
+ private PlayClerkUserInfoServiceImpl clerkUserInfoService;
+
+ @Mock
+ private ISysTenantService sysTenantService;
+
+ @Mock
+ private RedisCache redisCache;
+
+ @Mock
+ private SysUserService userService;
+
+ @InjectMocks
+ private JwtAuthenticationTokenFilter filter;
+
+ @Test
+ void wxPayCallbackBypassesTenantKeyAndAuth__covers_GW_003() throws ServletException, IOException {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/wx/pay/jsCallback");
+ request.setServletPath("/wx/pay/jsCallback");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(response.getHeader("X-Chain")).isEqualTo("called");
+ verify(resolver, never()).resolveException(any(), any(), any(), any());
+ }
+
+ @Test
+ void wxRouteMissingTenantKeyTriggersResolverAndStopsChain__covers_GW_001__covers_GW_002() throws ServletException, IOException {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/custom/queryById");
+ request.setServletPath("/wx/custom/queryById");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(response.getHeader("X-Chain")).isNull();
+ verify(resolver).resolveException(eq(request), eq(response), eq(null), any(CustomException.class));
+ }
+
+ @Test
+ void noLoginWxRouteInvalidTenantTriggersResolver__covers_GW_004() throws ServletException, IOException {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/common/area/tree");
+ request.setServletPath("/wx/common/area/tree");
+ request.addHeader("tenantkey", "unknown-tenant");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
+
+ when(sysTenantService.selectByTenantKey("unknown-tenant")).thenReturn(null);
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(response.getHeader("X-Chain")).isNull();
+ verify(resolver).resolveException(eq(request), eq(response), eq(null), any(CustomException.class));
+ }
+
+ @Test
+ void noLoginWxRouteTenantLookupThrowsReturns401Json__covers_GW_004() throws ServletException, IOException {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/common/area/tree");
+ request.setServletPath("/wx/common/area/tree");
+ request.addHeader("tenantkey", "tenantkey-1");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
+
+ when(sysTenantService.selectByTenantKey("tenantkey-1")).thenThrow(new RuntimeException("boom"));
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(response.getHeader("X-Chain")).isNull();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getContentAsString()).contains("\"code\":401");
+ }
+
+ @Test
+ void loginRequiredWxRouteTenantLookupThrowsReturns401Json__covers_GW_005__covers_GW_006() throws ServletException, IOException {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/custom/queryById");
+ request.setServletPath("/wx/custom/queryById");
+ request.addHeader("tenantkey", "tenantkey-1");
+ request.addHeader(com.starry.common.constant.Constants.CUSTOM_USER_LOGIN_TOKEN, "Bearer bad-token");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
+
+ when(tokenService.getWxUserIdByToken(any())).thenThrow(new RuntimeException("bad token"));
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(response.getHeader("X-Chain")).isNull();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getContentAsString()).contains("\"code\":401");
+ }
+
+ @Test
+ void wxRouteWithValidTenantPassesChainAndSetsTenantContext__covers_GW_004() throws ServletException, IOException {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/common/area/tree");
+ request.setServletPath("/wx/common/area/tree");
+ request.addHeader("tenantkey", "tenantkey-1");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
+
+ SysTenantEntity tenant = new SysTenantEntity();
+ tenant.setTenantId("tenant-1");
+ tenant.setTenantTime(new Date(System.currentTimeMillis() + 3600_000));
+ when(sysTenantService.selectByTenantKey("tenantkey-1")).thenReturn(tenant);
+ when(sysTenantService.selectSysTenantByTenantId("tenant-1")).thenReturn(tenant);
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(response.getHeader("X-Chain")).isEqualTo("called");
+ verify(resolver, never()).resolveException(any(), any(), any(), any());
+ }
+
+ @Test
+ void getTenantIdUsesClerkTokenWhenProvided__covers_GW_005() {
+ when(tokenService.getWxUserIdByToken(any())).thenReturn("user-1");
+ when(redisCache.getCacheObject("TENANT_INFO:user-1")).thenReturn("tenant-from-redis");
+
+ PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
+ clerk.setTenantId("tenant-clerk");
+ when(clerkUserInfoService.selectById("user-1")).thenReturn(clerk);
+
+ String tenantId = filter.getTenantId("Bearer clerk", "Bearer custom", "tenantkey-1");
+ assertThat(tenantId).isEqualTo("tenant-clerk");
+ }
+
+ @Test
+ void getTenantIdUsesCustomTokenWhenNoClerkToken__covers_GW_006() {
+ when(tokenService.getWxUserIdByToken(any())).thenReturn("user-2");
+ when(redisCache.getCacheObject("TENANT_INFO:user-2")).thenReturn("tenant-from-redis");
+
+ PlayCustomUserInfoEntity custom = new PlayCustomUserInfoEntity();
+ custom.setTenantId("tenant-custom");
+ when(customUserInfoService.selectById("user-2")).thenReturn(custom);
+
+ String tenantId = filter.getTenantId(null, "Bearer custom", "tenantkey-1");
+ assertThat(tenantId).isEqualTo("tenant-custom");
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java b/play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java
index 19ebea8..3bcf902 100644
--- a/play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java
+++ b/play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java
@@ -32,7 +32,9 @@ import javax.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class WxPkApiTest extends AbstractApiTest {
@@ -91,6 +93,12 @@ class WxPkApiTest extends AbstractApiTest {
@Resource
private IPlayOrderInfoService orderInfoService;
+ @Resource
+ private com.starry.admin.modules.weichat.controller.WxPkController wxPkController;
+
+ @Resource
+ private GlobalExceptionHandler globalExceptionHandler;
+
@BeforeEach
void clearRedis() {
if (stringRedisTemplate.getConnectionFactory() == null) {
@@ -100,7 +108,7 @@ class WxPkApiTest extends AbstractApiTest {
}
@Test
- void clerkLiveShouldRejectWhenClerkIdMissing() throws Exception {
+ void clerkLiveShouldRejectWhenClerkIdMissing__covers_PK_001() throws Exception {
mockMvc.perform(get("/wx/pk/clerk/live")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
@@ -110,7 +118,7 @@ class WxPkApiTest extends AbstractApiTest {
}
@Test
- void clerkLiveShouldReturnInactiveWhenNoActivePk() throws Exception {
+ void clerkLiveShouldReturnInactiveWhenNoActivePk__covers_PK_002() throws Exception {
String clerkId = newClerkId();
mockMvc.perform(get("/wx/pk/clerk/live")
.param(PARAM_CLERK_ID, clerkId)
@@ -151,7 +159,7 @@ class WxPkApiTest extends AbstractApiTest {
}
@Test
- void clerkLiveShouldReturnInactiveWhenToBeStarted() throws Exception {
+ void clerkLiveShouldReturnInactiveWhenToBeStarted__covers_PK_003() throws Exception {
LocalDateTime now = LocalDateTime.now();
String clerkId = newClerkId();
PlayClerkPkEntity pk = buildPk(newPkId(), clerkId, newClerkId(),
@@ -170,7 +178,20 @@ class WxPkApiTest extends AbstractApiTest {
}
@Test
- void upcomingShouldReturnInactiveWhenRedisEmpty() throws Exception {
+ void upcomingShouldRejectWhenTenantMissing__covers_PK_004() throws Exception {
+ com.starry.common.context.CustomSecurityContextHolder.remove();
+ MockMvc standalone = MockMvcBuilders.standaloneSetup(wxPkController)
+ .setControllerAdvice(globalExceptionHandler)
+ .build();
+
+ standalone.perform(get("/wx/pk/upcoming"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode()))
+ .andExpect(jsonPath("$.message").value("租户信息缺失"));
+ }
+
+ @Test
+ void upcomingShouldReturnInactiveWhenRedisEmpty__covers_PK_005() throws Exception {
mockMvc.perform(get("/wx/pk/upcoming")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
@@ -179,7 +200,7 @@ class WxPkApiTest extends AbstractApiTest {
}
@Test
- void upcomingShouldReturnUpcomingWhenRedisHasPk() throws Exception {
+ void upcomingShouldReturnUpcomingWhenRedisHasPk__covers_PK_005() throws Exception {
LocalDateTime now = LocalDateTime.now();
String pkId = newPkId();
PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(),
@@ -358,7 +379,7 @@ class WxPkApiTest extends AbstractApiTest {
}
@Test
- void clerkScheduleShouldRejectWhenClerkIdMissing() throws Exception {
+ void clerkScheduleShouldRejectWhenClerkIdMissing__covers_PK_006() throws Exception {
mockMvc.perform(get("/wx/pk/clerk/schedule")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
@@ -409,7 +430,7 @@ class WxPkApiTest extends AbstractApiTest {
}
@Test
- void clerkScheduleShouldApplyLimitDefaultWhenMissing() throws Exception {
+ void clerkScheduleShouldApplyLimitDefaultWhenMissing__covers_PK_006() throws Exception {
LocalDateTime now = LocalDateTime.now();
String clerkId = newClerkId();
SecurityUtils.setTenantId(DEFAULT_TENANT);
diff --git a/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java
index e95ef1b..4a49924 100644
--- a/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java
+++ b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java
@@ -1,6 +1,8 @@
package com.starry.admin.modules.weichat.service;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
@@ -8,17 +10,26 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.starry.admin.common.exception.CustomException;
+import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.enums.ListingStatus;
import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.order.module.constant.OrderConstant;
+import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.admin.modules.system.service.impl.SysTenantServiceImpl;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.context.CustomSecurityContextHolder;
+import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.api.WxMpTemplateMsgService;
+import me.chanjar.weixin.mp.api.WxMpUserService;
+import me.chanjar.weixin.mp.bean.result.WxMpUser;
import me.chanjar.weixin.mp.bean.template.WxMpTemplateData;
import me.chanjar.weixin.mp.bean.template.WxMpTemplateMessage;
import org.junit.jupiter.api.Test;
@@ -26,6 +37,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
+import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -69,7 +81,35 @@ class WxCustomMpServiceTest {
}
@Test
- void sendCreateOrderMessageUsesOrderLabelResolver() throws WxErrorException {
+ void proxyWxMpServiceRejectsMissingTenantId__covers_MP_001() {
+ try {
+ SecurityUtils.setTenantId("");
+ assertThatThrownBy(wxCustomMpService::proxyWxMpService)
+ .isInstanceOf(CustomException.class)
+ .hasMessage("系统错误,租户ID不能为空");
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ void getWxPayRejectsWhenTenantMissingMchId__covers_MP_002() {
+ try {
+ SecurityUtils.setTenantId(TENANT_ID);
+ SysTenantEntity tenant = buildTenant();
+ tenant.setMchId("");
+ when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
+
+ assertThatThrownBy(wxCustomMpService::getWxPay)
+ .isInstanceOf(CustomException.class)
+ .hasMessage("商户号不能为空,请联系平台方进行配置");
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ void sendCreateOrderMessageUsesOrderLabelResolver__covers_MP_003() throws WxErrorException {
SysTenantEntity tenant = buildTenant();
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
@@ -114,7 +154,7 @@ class WxCustomMpServiceTest {
}
@Test
- void sendCreateOrderMessageBatchFiltersInactiveClerksAndForwardsLabel() throws WxErrorException {
+ void sendCreateOrderMessageBatchFiltersInactiveClerksAndForwardsLabel__covers_MP_004() throws WxErrorException {
SysTenantEntity tenant = buildTenant();
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
@@ -170,4 +210,103 @@ class WxCustomMpServiceTest {
.extracting(WxMpTemplateData::getValue)
.isEqualTo("指定单");
}
+
+ @Test
+ void sendBalanceMessageBuildsRechargeTemplateData__covers_MP_005() throws WxErrorException {
+ try {
+ SecurityUtils.setTenantId(TENANT_ID);
+
+ SysTenantEntity tenant = buildTenant();
+ tenant.setChongzhichenggongTemplateId("template-recharge-success");
+ tenant.setTenantName("租户名称");
+ when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
+
+ when(wxMpService.switchoverTo(tenant.getAppId())).thenReturn(wxMpService);
+ when(wxMpService.getTemplateMsgService()).thenReturn(templateMsgService);
+
+ PlayCustomUserInfoEntity customUser = new PlayCustomUserInfoEntity();
+ customUser.setId("customer-1");
+ customUser.setOpenid("openid-customer");
+ when(customUserInfoService.selectById(customUser.getId())).thenReturn(customUser);
+
+ PlayOrderInfoEntity order = new PlayOrderInfoEntity();
+ order.setTenantId(TENANT_ID);
+ order.setPurchaserBy(customUser.getId());
+ order.setOrderMoney(new BigDecimal("10.00"));
+
+ wxCustomMpService.sendBalanceMessage(order);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(WxMpTemplateMessage.class);
+ verify(templateMsgService).sendTemplateMsg(captor.capture());
+
+ WxMpTemplateMessage message = captor.getValue();
+ assertThat(message.getTemplateId()).isEqualTo("template-recharge-success");
+ assertThat(message.getToUser()).isEqualTo("openid-customer");
+ assertThat(message.getUrl()).isEqualTo("https://tenant-key.julyharbor.com/user/");
+
+ assertThat(message.getData())
+ .filteredOn(item -> "amount2".equals(item.getName()))
+ .singleElement()
+ .extracting(WxMpTemplateData::getValue)
+ .isEqualTo("10.00");
+ assertThat(message.getData())
+ .filteredOn(item -> "amount17".equals(item.getName()))
+ .singleElement()
+ .extracting(WxMpTemplateData::getValue)
+ .isEqualTo("0");
+ assertThat(message.getData())
+ .filteredOn(item -> "thing10".equals(item.getName()))
+ .singleElement()
+ .extracting(WxMpTemplateData::getValue)
+ .isEqualTo("租户名称");
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ void sendOrderFinishMessageOnlyTriggersForPlaceType12__covers_MP_006() throws WxErrorException {
+ PlayOrderInfoEntity order = new PlayOrderInfoEntity();
+ order.setPlaceType("3");
+
+ wxCustomMpService.sendOrderFinishMessage(order);
+
+ verify(templateMsgService, Mockito.never()).sendTemplateMsg(any());
+ }
+
+ @Test
+ void asyncWrappersSwallowExceptionsFromUnderlyingSend__covers_MP_007() {
+ doAnswer(invocation -> {
+ Runnable runnable = invocation.getArgument(0);
+ runnable.run();
+ return null;
+ }).when(executor).execute(any(Runnable.class));
+
+ WxCustomMpService spy = Mockito.spy(wxCustomMpService);
+ Mockito.doThrow(new RuntimeException("boom")).when(spy).sendOrderFinishMessage(any());
+
+ PlayOrderInfoEntity order = new PlayOrderInfoEntity();
+ order.setId("order-async-1");
+
+ assertThatCode(() -> spy.sendOrderFinishMessageAsync(order)).doesNotThrowAnyException();
+ verify(executor).execute(any(Runnable.class));
+ }
+
+ @Test
+ void checkSubscribeThrowsWhenUserNotSubscribed__covers_MP_008() throws WxErrorException {
+ SysTenantEntity tenant = buildTenant();
+ when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
+ when(wxMpService.switchoverTo(tenant.getAppId())).thenReturn(wxMpService);
+
+ WxMpUserService userService = Mockito.mock(WxMpUserService.class);
+ when(wxMpService.getUserService()).thenReturn(userService);
+
+ WxMpUser user = new WxMpUser();
+ user.setSubscribe(false);
+ when(userService.userInfo("openid-1")).thenReturn(user);
+
+ assertThatThrownBy(() -> wxCustomMpService.checkSubscribeThrowsExp("openid-1", TENANT_ID))
+ .isInstanceOf(ServiceException.class)
+ .hasMessage("请先关注公众号然后再来使用系统~");
+ }
}
diff --git a/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxOauthServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxOauthServiceTest.java
new file mode 100644
index 0000000..a3f9d67
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxOauthServiceTest.java
@@ -0,0 +1,213 @@
+package com.starry.admin.modules.weichat.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import cn.hutool.http.HttpRequest;
+import com.starry.admin.common.exception.ServiceException;
+import com.starry.admin.common.oss.service.IOssFileService;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.module.enums.ClerkRoleStatus;
+import com.starry.admin.modules.clerk.module.enums.ListingStatus;
+import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
+import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
+import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService;
+import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
+import com.starry.admin.utils.SecurityUtils;
+import java.io.ByteArrayInputStream;
+import me.chanjar.weixin.common.bean.WxOAuth2UserInfo;
+import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
+import me.chanjar.weixin.mp.api.WxMpService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class WxOauthServiceTest {
+
+ @Mock
+ private WxCustomMpService wxCustomMpService;
+
+ @Mock
+ private IPlayCustomUserInfoService customUserInfoService;
+
+ @Mock
+ private IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Mock
+ private IPlayClerkLevelInfoService playClerkLevelInfoService;
+
+ @Mock
+ private IPlayCustomLevelInfoService playCustomLevelInfoService;
+
+ @Mock
+ private IOssFileService ossFileService;
+
+ @InjectMocks
+ private WxOauthService wxOauthService;
+
+ @Test
+ void getWxOAuth2AccessTokenRejectsBlankCode__covers_OAUTH_009() {
+ try {
+ wxOauthService.getWxOAuth2AccessToken(" ");
+ } catch (ServiceException ex) {
+ assertThat(ex.getMessage()).isEqualTo("不能为空");
+ return;
+ }
+ throw new AssertionError("Expected ServiceException");
+ }
+
+ @Test
+ void customUserLoginCreatesNewUserAndUpdatesLastLogin__covers_OAUTH_009() throws Exception {
+ WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
+ when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
+
+ WxOAuth2AccessToken token = new WxOAuth2AccessToken();
+ token.setOpenId("openid-1");
+ when(wxMpService.getOAuth2Service().getAccessToken("code-1")).thenReturn(token);
+
+ WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
+ userInfo.setOpenid("openid-1");
+ userInfo.setNickname("Nick");
+ userInfo.setHeadImgUrl("https://example.com/avatar.png");
+ when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
+
+ when(customUserInfoService.selectByOpenid("openid-1")).thenReturn(null);
+ com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity defaultLevel =
+ new com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity();
+ defaultLevel.setId("lvl-1");
+ when(playCustomLevelInfoService.getDefaultLevel()).thenReturn(defaultLevel);
+
+ String userId = wxOauthService.customUserLogin("code-1");
+ assertThat(userId).isNotBlank();
+ verify(customUserInfoService).saveOrUpdate(any(PlayCustomUserInfoEntity.class));
+ }
+
+ @Test
+ void clerkUserLoginCreatesNewClerkWithAvatarAndDefaultStates__covers_OAUTH_010() throws Exception {
+ SecurityUtils.setTenantId("tenant-1");
+
+ WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
+ when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
+
+ WxOAuth2AccessToken token = new WxOAuth2AccessToken();
+ token.setOpenId("openid-clerk-1");
+ when(wxMpService.getOAuth2Service().getAccessToken("code-2")).thenReturn(token);
+
+ WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
+ userInfo.setOpenid("openid-clerk-1");
+ userInfo.setNickname("Lily");
+ userInfo.setHeadImgUrl("https://example.com/avatar.png");
+ when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
+
+ when(clerkUserInfoService.selectByOpenid("openid-clerk-1")).thenReturn(null);
+ com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity defaultClerkLevel =
+ new com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity();
+ defaultClerkLevel.setId("lvl-clerk");
+ when(playClerkLevelInfoService.getDefaultLevel()).thenReturn(defaultClerkLevel);
+ when(ossFileService.upload(any(), eq("tenant-1"), eq("image"))).thenReturn("https://oss.example/avatar.jpg");
+
+ HttpRequest httpRequest = mock(HttpRequest.class, Mockito.RETURNS_DEEP_STUBS);
+ try (MockedStatic mocked = Mockito.mockStatic(HttpRequest.class)) {
+ mocked.when(() -> HttpRequest.get("https://example.com/avatar.png")).thenReturn(httpRequest);
+ when(httpRequest.execute().bodyStream()).thenReturn(new ByteArrayInputStream("img".getBytes()));
+
+ String clerkId = wxOauthService.clerkUserLogin("code-2");
+ assertThat(clerkId).isNotBlank();
+
+ verify(clerkUserInfoService).create(Mockito.argThat(entity -> {
+ if (!"openid-clerk-1".equals(entity.getOpenid())) {
+ return false;
+ }
+ if (!"https://oss.example/avatar.jpg".equals(entity.getAvatar())) {
+ return false;
+ }
+ return "lvl-clerk".equals(entity.getLevelId())
+ && ClerkRoleStatus.NON_CLERK.getCode().equals(entity.getClerkState())
+ && OnboardingStatus.ACTIVE.getCode().equals(entity.getOnboardingState())
+ && ListingStatus.LISTED.getCode().equals(entity.getListingState());
+ }));
+ }
+ }
+
+ @Test
+ void clerkUserLoginRestoresDeletedClerk__covers_OAUTH_010() throws Exception {
+ WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
+ when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
+
+ WxOAuth2AccessToken token = new WxOAuth2AccessToken();
+ token.setOpenId("openid-restore");
+ when(wxMpService.getOAuth2Service().getAccessToken("code-restore")).thenReturn(token);
+
+ WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
+ userInfo.setOpenid("openid-restore");
+ userInfo.setNickname("Restored");
+ userInfo.setHeadImgUrl("https://example.com/avatar.png");
+ when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
+
+ PlayClerkUserInfoEntity existing = new PlayClerkUserInfoEntity();
+ existing.setId("clerk-restore");
+ existing.setOpenid("openid-restore");
+ existing.setDeleted(true);
+ existing.setToken("old");
+ existing.setOnlineState("1");
+ existing.setOnboardingState("");
+ existing.setListingState("");
+ existing.setClerkState("");
+
+ when(clerkUserInfoService.selectByOpenid("openid-restore")).thenReturn(existing);
+
+ String clerkId = wxOauthService.clerkUserLogin("code-restore");
+ assertThat(clerkId).isEqualTo("clerk-restore");
+ assertThat(existing.getDeleted()).isFalse();
+ assertThat(existing.getToken()).isEqualTo("empty");
+ assertThat(existing.getOnlineState()).isEqualTo("0");
+ assertThat(existing.getOnboardingState()).isEqualTo(OnboardingStatus.ACTIVE.getCode());
+ assertThat(existing.getListingState()).isEqualTo(ListingStatus.LISTED.getCode());
+ assertThat(existing.getClerkState()).isEqualTo(ClerkRoleStatus.NON_CLERK.getCode());
+
+ verify(clerkUserInfoService).update(eq(null), any());
+ verify(clerkUserInfoService).ensureClerkSessionIsValid(existing);
+ }
+
+ @Test
+ void clerkUserLoginDoesNotDownloadAvatarWhenAlreadyPresent__covers_OAUTH_010() throws Exception {
+ WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
+ when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
+
+ WxOAuth2AccessToken token = new WxOAuth2AccessToken();
+ token.setOpenId("openid-existing");
+ when(wxMpService.getOAuth2Service().getAccessToken("code-existing")).thenReturn(token);
+
+ WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
+ userInfo.setOpenid("openid-existing");
+ userInfo.setNickname("Exists");
+ userInfo.setHeadImgUrl("https://example.com/avatar.png");
+ when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
+
+ PlayClerkUserInfoEntity existing = new PlayClerkUserInfoEntity();
+ existing.setId("clerk-existing");
+ existing.setOpenid("openid-existing");
+ existing.setDeleted(false);
+ existing.setAvatar("https://oss.example/already.jpg");
+
+ when(clerkUserInfoService.selectByOpenid("openid-existing")).thenReturn(existing);
+
+ String clerkId = wxOauthService.clerkUserLogin("code-existing");
+ assertThat(clerkId).isEqualTo("clerk-existing");
+
+ verify(ossFileService, never()).upload(any(), anyString(), anyString());
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxTokenServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxTokenServiceTest.java
new file mode 100644
index 0000000..57df0de
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxTokenServiceTest.java
@@ -0,0 +1,36 @@
+package com.starry.admin.modules.weichat.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.jsonwebtoken.ExpiredJwtException;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+class WxTokenServiceTest {
+
+ @Test
+ void tokenRoundTripSupportsBearerPrefix__covers_TOK_001() {
+ WxTokenService service = new WxTokenService();
+ ReflectionTestUtils.setField(service, "secret", "apitest-secret");
+ ReflectionTestUtils.setField(service, "expireTime", 60);
+
+ String token = service.createWxUserToken("user-1");
+ assertThat(service.getWxUserIdByToken(token)).isEqualTo("user-1");
+ assertThat(service.getWxUserIdByToken("Bearer " + token)).isEqualTo("user-1");
+ }
+
+ @Test
+ void expiredTokenFailsToParse__covers_TOK_002() {
+ WxTokenService service = new WxTokenService();
+ ReflectionTestUtils.setField(service, "secret", "apitest-secret");
+ ReflectionTestUtils.setField(service, "expireTime", -1);
+
+ String token = service.createWxUserToken("user-2");
+ try {
+ service.getWxUserIdByToken(token);
+ } catch (ExpiredJwtException ex) {
+ return;
+ }
+ throw new AssertionError("Expected ExpiredJwtException");
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/modules/weichat/utils/WxFileUtilsTest.java b/play-admin/src/test/java/com/starry/admin/modules/weichat/utils/WxFileUtilsTest.java
new file mode 100644
index 0000000..e4e90b6
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/modules/weichat/utils/WxFileUtilsTest.java
@@ -0,0 +1,25 @@
+package com.starry.admin.modules.weichat.utils;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.starry.admin.common.exception.CustomException;
+import java.io.File;
+import java.nio.file.Files;
+import org.junit.jupiter.api.Test;
+
+class WxFileUtilsTest {
+
+ @Test
+ void audioConvert2Mp3RejectsEmptyFile__covers_COM_006() throws Exception {
+ File source = Files.createTempFile("apitest-empty-audio-", ".amr").toFile();
+ File target = Files.createTempFile("apitest-empty-audio-", ".mp3").toFile();
+ try {
+ assertThatThrownBy(() -> WxFileUtils.audioConvert2Mp3(source, target))
+ .isInstanceOf(CustomException.class)
+ .hasMessage("音频文件上传失败");
+ } finally {
+ Files.deleteIfExists(source.toPath());
+ Files.deleteIfExists(target.toPath());
+ }
+ }
+}