From 985b35cd90e56ceb435a1bae92d4f20012062b15 Mon Sep 17 00:00:00 2001 From: irving Date: Mon, 12 Jan 2026 18:54:14 -0500 Subject: [PATCH] test: add wechat integration test suite - Add llm/wechat-subsystem-test-matrix.md and tests covering Wx controllers/services\n- Make ApiTestDataSeeder personnel group seeding idempotent for full-suite stability --- llm/wechat-subsystem-test-matrix.md | 359 +++++++++++++ play-admin/pom.xml | 5 + .../common/apitest/ApiTestDataSeeder.java | 62 ++- .../admin/api/MockWxMpServiceConfig.java | 2 +- .../admin/api/WxArticleControllerApiTest.java | 264 ++++++++++ .../starry/admin/api/WxAuthAspectApiTest.java | 150 ++++++ .../api/WxBlindBoxControllerApiTest.java | 293 +++++++++++ .../admin/api/WxBlindBoxOrderApiTest.java | 4 +- .../admin/api/WxClerkAlbumUpdateApiTest.java | 57 +- .../api/WxClerkControllerUserApiTest.java | 488 +++++++++++++++++ .../api/WxClerkMediaControllerApiTest.java | 128 ++++- ...xClerkMediaControllerEndpointsApiTest.java | 276 ++++++++++ .../api/WxClerkOrderCompleteApiTest.java | 193 +++++++ .../api/WxClerkWagesControllerApiTest.java | 203 +++++++ .../admin/api/WxCommonControllerApiTest.java | 93 ++++ .../WxCommonControllerAudioUploadApiTest.java | 74 +++ .../admin/api/WxCouponControllerApiTest.java | 138 ++++- .../api/WxCustomControllerMiscApiTest.java | 277 ++++++++++ .../admin/api/WxCustomGiftOrderApiTest.java | 2 +- .../api/WxCustomOrderEvaluationApiTest.java | 56 ++ .../admin/api/WxCustomOrderQueryApiTest.java | 2 +- .../admin/api/WxCustomRandomOrderApiTest.java | 108 +++- .../admin/api/WxCustomRewardOrderApiTest.java | 2 +- .../api/WxCustomSpecifiedOrderApiTest.java | 2 +- .../admin/api/WxOauthControllerApiTest.java | 290 ++++++++++ .../api/WxOrderInfoControllerApiTest.java | 214 +++++++- .../admin/api/WxPayControllerApiTest.java | 494 ++++++++++++++++++ .../admin/api/WxShopControllerApiTest.java | 139 +++++ .../api/WxWithdrawControllerApiTest.java | 148 +++++- .../api/WxWithdrawPayeeControllerApiTest.java | 173 ++++++ .../JwtAuthenticationTokenFilterTest.java | 193 +++++++ .../starry/admin/modules/pk/WxPkApiTest.java | 35 +- .../service/WxCustomMpServiceTest.java | 143 ++++- .../weichat/service/WxOauthServiceTest.java | 213 ++++++++ .../weichat/service/WxTokenServiceTest.java | 36 ++ .../weichat/utils/WxFileUtilsTest.java | 25 + 36 files changed, 5293 insertions(+), 48 deletions(-) create mode 100644 llm/wechat-subsystem-test-matrix.md create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxArticleControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxAuthAspectApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxBlindBoxControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxClerkControllerUserApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerEndpointsApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxClerkOrderCompleteApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxClerkWagesControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCommonControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCommonControllerAudioUploadApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxCustomControllerMiscApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxOauthControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxPayControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxShopControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxWithdrawPayeeControllerApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/common/security/filter/JwtAuthenticationTokenFilterTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxOauthServiceTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxTokenServiceTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/weichat/utils/WxFileUtilsTest.java 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()); + } + } +}