commit 360c2c2d7164092bb2fde03bc59b9db321364773 Author: 你的GitHub用户名 <3457189498@qq.com> Date: Fri Jun 26 10:51:35 2026 +0800 基本完成,测试功能,等待修改 Co-authored-by: Cursor diff --git a/.cursor/debug-efb842.log b/.cursor/debug-efb842.log new file mode 100644 index 0000000..3198825 --- /dev/null +++ b/.cursor/debug-efb842.log @@ -0,0 +1,48 @@ +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse Road Farmers Branch TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204915} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"delivery","label":"5678 Distribution Drive Fort Worth TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204925} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse Road Farmers Branch TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204928} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"delivery","label":"5678 Distribution Drive Fort Worth TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204929} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse Road Farmers Branch TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204932} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201536196} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["EisxMjM0IFdhcmVob3Vz"]},"timestamp":1782201537665} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse StreetLos Angeles, CA","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateCount":1,"allPlaceIds":["EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol"]},"timestamp":1782201538143} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateIndex":0},"timestamp":1782201538477} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201541494} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201545036} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201557222} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol commit 失败;原因:widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler(可能仅更新了 input 文本);路径:keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782201557223} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201589622} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["EisxMjM0IFdhcmVob3Vz"]},"timestamp":1782201597002} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":2,"placeIds":["EjMxMjM0IFdhcmVob3Vz"]},"timestamp":1782201609716} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse StreetLos Angeles, CA","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateCount":2,"allPlaceIds":["EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","EjMxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDYWxpZm9ybmlhLCBVU0EiMRIvChQKEgnLANPoJcbCgBG6jPkcgl7bSBDSCSoUChIJIRokdSbGwoARjZhcWiHJmiU"]},"timestamp":1782201610080} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateIndex":0},"timestamp":1782201610427} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2}},"timestamp":1782201613437} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2}},"timestamp":1782201616967} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2}},"timestamp":1782201629145} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2},"reason":"place/EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol commit 失败;原因:widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler(可能仅更新了 input 文本);路径:keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782201629146} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201942106} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["ChIJT3S4eOm20YgRUeQO"]},"timestamp":1782201943875} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"La Concha Key West, Autograph Collection Duval Street, FL","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateCount":1,"allPlaceIds":["ChIJT3S4eOm20YgRUeQOBiQSjPc"]},"timestamp":1782201944190} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateIndex":0},"timestamp":1782201944527} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201947532} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201951074} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201963262} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/ChIJT3S4eOm20YgRUeQOBiQSjPc commit 失败;原因:widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler(可能仅更新了 input 文本);路径:keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782201963263} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201993208} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["ChIJT3S4eOm20YgRUeQO"]},"timestamp":1782201994545} +{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"La Concha Key West, Autograph Collection Duval Street, FL","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateCount":1,"allPlaceIds":["ChIJT3S4eOm20YgRUeQOBiQSjPc"]},"timestamp":1782201994911} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateIndex":0},"timestamp":1782201995251} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201998255} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782202001788} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782202013999} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/ChIJT3S4eOm20YgRUeQOBiQSjPc commit 失败;原因:widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler(可能仅更新了 input 文本);路径:keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782202014000} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782209499595} +{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["EisxMjM0IFdhcmVob3Vz"]},"timestamp":1782209500402} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209506528} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209511713} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209517158} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-keyboard","data":{"clickPath":"description-pointer → description-click → keyboard-bound-0","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateIndex":0},"timestamp":1782209517159} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209522700} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209528490} +{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209540492} +{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"description-pointer → description-click → keyboard-bound-0 → keyboard-full-0 → description-pointer-retry","diag":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol commit 失败;原因:widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler(可能仅更新了 input 文本);路径:description-pointer → description-click → keyboard-bound-0 → keyboard-full-0 → description-pointer-retry"},"timestamp":1782209540493} diff --git a/.cursor/rules/karpathy-guidelines.mdc b/.cursor/rules/karpathy-guidelines.mdc new file mode 100644 index 0000000..edd317f --- /dev/null +++ b/.cursor/rules/karpathy-guidelines.mdc @@ -0,0 +1,70 @@ +--- +description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria. +alwaysApply: true +--- + +# Karpathy behavioral guidelines + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/.cursor/rules/project-rules.mdc b/.cursor/rules/project-rules.mdc new file mode 100644 index 0000000..3955bb4 --- /dev/null +++ b/.cursor/rules/project-rules.mdc @@ -0,0 +1,35 @@ +--- +description: 项目统一规则入口 +alwaysApply: true +--- + +你在本项目执行任何编码任务前,必须先读取并遵循 `cursor_project_rules` 目录中的规则文件。 + +强制约束: + + ## 不得脱离 `cursor_project_rules` 的上下文进行实现。 + ## 新功能必须按 `cursor_project_rules/implementation-plan.mdc` 的步骤顺序执行。 + ## 每完成一个步骤,必须在 `implementation-plan.mdc` 中写入: + - `Done` + - 两行本次执行记录 + ## 修改代码时保持最小变更,原则:执行修改必须遵循零风险,最小代码修改量,不能因为本次的修改而影响了其他功能正常使用,禁止修改与计划无关的代码。安全第一:不得引入新 bug,不破坏现有逻辑 + ## 对新增或变更逻辑补充对应测试(如项目存在测试框架)。 + ## 要求: + ### 必须100%精准定位错误根源:具体到哪个文件、第几行(或哪几行)、哪个函数/组件/查询/中间件。 + ### 逐层深度分析错误成因:数据库层(SQL/ORM/索引/事务)、后端逻辑(API、控制器、服务、验证、中间件)、前端(React/Vue/Svelte组件、状态管理、hooks、请求封装)、组件库、构建工具、环境变量、部署配置全部覆盖。 + ### 给出100%能彻底解决的修改方案:直接贴出修改后的完整代码块(只改动必要部分,用```标注语言),并用注释标明「为何这样改」。 + ### 方案必须只能改最少文件、最少行数,杜绝过度重构。 + ### 整场对话诊断+方案总行数控制在100行以内,精炼到底。所有输出的计划或总报告,不得超过100行。现在开始,请先复述你理解的错误现象,然后直接报出「根源在:xxx文件 第xx行」 + ## 所有前端界面文本必须使用中文,在设计前端时,应考虑用户为中文环境下使用,系统应该设计为中文系统,能被看到的都应该翻译为中文,除非是用户特殊说明。 + ## 界面文案必须干净,不得包含需求文档引用标记 + + ### 并发场景必须考虑:重复提交、资源竞争、原子性、锁机制 + ### 外部调用必须处理:超时、失败、重试、熔断、降级 + ### 所有业务逻辑必须覆盖:异常流程、错误流程、边界场景、反向场景 + ### 禁止只处理理想流程,必须覆盖所有可能出现的异常与风险点 + ### 代码必须稳定、安全、可直接上线,不允许脆弱逻辑请直接输出**完整、健壮、包含所有异常处理与安全机制**的最终代码。 + ## 请在系统开发与初始化流程中,自动完成以下动作: + ### 从需求文档中提取所有示例、实例、案例、样例数据、演示值、测试用例、状态示例。 + ### 核心响应协议:深度思考优先 + ## If a fix is attempted 3 times without structural improvement in logs or system state, stop all local fixes and perform full root cause re-analysis across all layers (UI, RPA, backend, queue, pricing). Local patching is prohibited until root cause layer is correctly identified. + diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..40a2be6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.git +.env +.env.* +.mypy_cache +*.md +docs +.cursor +cursor_project_rules +scripts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..de5b116 --- /dev/null +++ b/.env.example @@ -0,0 +1,98 @@ +# 宿主机 MySQL 端口(docker-compose 映射,避开本机已占用的 3306) +MYSQL_HOST_PORT=3307 +MYSQL_ROOT_PASSWORD=changeme +JWT_SECRET=your-256-bit-secret-change-in-production + +# 宿主 Service Token 映射(JSON):token → { customerId, permissions[] } +HOST_SERVICE_TOKENS={"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}} + +# 管理端可见客户列表(逗号分隔,与 HOST_SERVICE_TOKENS 合并) +CUSTOMER_REGISTRY=CUST_001,CUST_002,CUST_003 + +# ── RPA(PRD v0.6 GD-15,headed 录制后填入)──────────────────────── +# 逗号分隔、按序探测;禁止 dashboard.mothership.com / www.mothership.com/quote +# 英文 locale 避免中文精简 widget(无 cargo editor);首页 + en-US 为推荐组合 +MOTHERSHIP_QUOTE_URLS=https://www.mothership.com/ +# 留空则 Playwright 默认;en-US headless 仅显示 zip 精简条,与地址 RPA 不兼容 +# RPA_BROWSER_LOCALE=en-US +RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json +RPA_MOCK_MODE=false +# 地址 commit 隔离:real=真实 MotherShip;mock=仅替代 commit(prove-rpa-chain 下游验证用) +RPA_ADDRESS_MODE=real +# 报价模式:direct=直连 axel API(默认,embed-demo 推荐)| widget=Playwright 页面交互 +RPA_QUOTE_MODE=direct +# 方案 A:widget 双地址 vault 注入(widget 模式默认 true;显式 false 可关闭) +# RPA_DUAL_ADDRESS_PATCH=true +# place commit 修复模式:event=Google事件触发(默认) | state=React注入 | legacy=原逻辑 +RPA_PLACE_FIX_MODE=event +# true 时注入 P0 实例拓扑/监听器指纹 + P2 fetch 劫持,fix-place 各阶段落盘 .rpa/place-diagnostic/ +RPA_PLACE_DIAG=false +# 有头调试:RPA_HEADED=true 等价 RPA_HEADLESS=false +RPA_HEADLESS=true +# RPA_HEADED=true +# 货物 drawer 填写完成后 page.pause()(仅人工验收时开启) +# RPA_CARGO_DRAWER_PAUSE=true +RPA_BROWSER_LOCALE=en-US +RPA_USE_PATCHRIGHT=false +# 反爬:playwright-extra stealth(与 patchright 二选一;部分页面会破坏 widget,默认 false) +RPA_USE_STEALTH=false +# 使用本机 Chrome/Edge,降低 HeadlessChrome 指纹(推荐 chrome) +# RPA_BROWSER_CHANNEL=chrome +# 真人入口流:先打开首页再点 Get Quote(携带 Referer/Cookie) +# RPA_HUMAN_ENTRY_FLOW=true +# RPA_QUOTE_WARMUP_URL=https://www.mothership.com/ +# headed 调试:慢动作毫秒;RPA_NETWORK_DEBUG=true 输出全部请求 +# RPA_SLOW_MO_MS=100 +# RPA_NETWORK_DEBUG=false +RPA_WORKER_ID= +RPA_QUEUE_ENABLED=true +# 驻留页单次会话加速(实验性,默认关;true 时需 worker + confirm 全链路) +RPA_PARKED_SESSION=false + +# Selector(Playwright codegen / record-mothership 产出,填表 + submit) +# 录制 115541 / 115059 — 中英双语,逗号分隔按序尝试 +RPA_SELECTOR_PICKUP_SECTION=#react-quoter-landing-plugin >> text=Pick up from,#react-quoter-landing-plugin >> text=Where to pick up,#react-quoter-landing-plugin >> text=从何处取货 +RPA_SELECTOR_DELIVERY_SECTION=#react-quoter-landing-plugin >> text=Deliver to,#react-quoter-landing-plugin >> text=Delivery to,#react-quoter-landing-plugin >> text=送货至 +RPA_SELECTOR_QUOTE_WIDGET=#react-quoter-landing-plugin +RPA_SELECTOR_PICKUP_STREET=#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"] +RPA_SELECTOR_DELIVERY_STREET=#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"] +RPA_SELECTOR_ADDRESS_SUGGESTION=#react-quoter-landing-plugin >> [role="listbox"] [role="option"],#react-quoter-landing-plugin >> li,.pac-item,[role="option"] +RPA_SELECTOR_CARGO_SECTION=#react-quoter-landing-plugin >> filter:text=Shipment details>>nth=4,#react-quoter-landing-plugin >> filter:text=Freight details>>nth=4,#react-quoter-landing-plugin >> filter:text=货运详情>>nth=4 +RPA_SELECTOR_SUBMIT=role=button[name="GET QUOTE"],role=button[name="Get freight quote"],role=button[name="Get a freight quote"],role=button[name="获取货运报价"] +# 报价 tab/price selector 已废弃(Network-First);可留空 +# RPA_SELECTOR_STANDARD_TAB= +# RPA_SELECTOR_GUARANTEED_TAB= +# RPA_SELECTOR_LOWEST_OPTION= +# RPA_SELECTOR_FASTEST_OPTION= +# RPA_SELECTOR_PRICE= + +# 报价 API endpoint(HAR 校准辅助,仅 telemetry;不参与 runtime 匹配) +# 阶段0 录证白名单:services.mothership.com/axel/quote +MOTHERSHIP_QUOTE_API_URL_PATTERNS=services.mothership.com/axel/quote +# 设为 true 时输出全部 REQ/RESP(定位 json=0) +RPA_QUOTE_NETWORK_VISIBILITY=false +# 设为 true 时契约失败日志输出 raw JSON body +RPA_QUOTE_DEBUG_RAW=false +# 设为 true 时日志输出 JSON 候选 URL(校准 HAR pinned 用) +RPA_QUOTE_NETWORK_SNIFF=false +RPA_SELECTOR_TRANSIT=text=business days +# 货物字段已改为 fillCargoInDrawer 动态 placeholder 定位,下列 env 仅兼容旧脚本,runtime 不再读取 +# RPA_SELECTOR_PALLET_QTY=... +# RPA_SELECTOR_WEIGHT=... +# RPA_SELECTOR_LENGTH=... +# RPA_SELECTOR_WIDTH=... +# RPA_SELECTOR_HEIGHT=... + +# 可选:仅当入口跳转 login 时使用 +MOTHERSHIP_EMAIL= +MOTHERSHIP_PASSWORD= + +# 已废弃:请改用 MOTHERSHIP_QUOTE_URLS +# MOTHERSHIP_QUOTE_URL= + +# 开发基础设施模式: auto | docker | native +DEV_INFRA_MODE=native + +# 本地 prisma migrate/seed 使用宿主机端口;容器内仍用 mysql:3306 +DATABASE_URL=mysql://root:changeme@localhost:3307/chajia +REDIS_URL=redis://localhost:6379 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b5eb92e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main, develop, "feature/**"] + pull_request: + branches: [main, develop] + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate Prisma schema + run: npm run db:validate + + - name: Lint (tsc) + run: npm run lint + + - name: Unit tests + run: npm run test:unit + + - name: Integration tests + run: npm run test:integration + env: + JWT_SECRET: ci-test-jwt-secret-32chars-min!! + HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}' + + - name: Build + run: npm run build + env: + JWT_SECRET: ci-test-jwt-secret-32chars-min!! + HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}' diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml new file mode 100644 index 0000000..a714d81 --- /dev/null +++ b/.github/workflows/e2e-nightly.yml @@ -0,0 +1,144 @@ +name: E2E Nightly + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: E2E tests + run: npm run test:e2e + env: + CI: true + JWT_SECRET: ci-test-jwt-secret-32chars-min!! + HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}' + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + go-no-go: + runs-on: ubuntu-latest + needs: e2e + if: always() + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - run: npm ci + + - name: Go-No-Go check (mock) + run: npm run go-no-go -- --mock + env: + RPA_MOCK_MODE: "true" + + probe: + runs-on: ubuntu-latest + needs: e2e + if: always() + timeout-minutes: 45 + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers (probe) + run: npx playwright install chromium --with-deps + + - name: RPA probe 3/3 (optional) + run: bash scripts/ci/run-probe-nightly.sh + env: + MOTHERSHIP_USERNAME: ${{ secrets.MOTHERSHIP_USERNAME }} + MOTHERSHIP_PASSWORD: ${{ secrets.MOTHERSHIP_PASSWORD }} + MOTHERSHIP_QUOTE_URLS: ${{ secrets.MOTHERSHIP_QUOTE_URLS }} + PROBE_NIGHTLY_LOG: probe-nightly.log + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: probe-nightly-log + path: probe-nightly.log + retention-days: 7 + if-no-files-found: ignore + + load: + runs-on: ubuntu-latest + needs: e2e + if: always() + + steps: + - uses: actions/checkout@v4 + + - name: Install k6 + run: | + sudo gpg -k + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update + sudo apt-get install k6 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - run: npm ci + + - name: Start app (mock mode) + run: | + npm run build + npm run start & + sleep 15 + env: + JWT_SECRET: ci-test-jwt-secret-32chars-min!! + HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}' + RPA_MOCK_MODE: "true" + DATABASE_URL: mysql://root:root@127.0.0.1:3306/chajia + REDIS_URL: redis://127.0.0.1:6379 + + - name: k6 rate-limit (smoke) + continue-on-error: true + run: k6 run --vus 1 --iterations 5 load-tests/rate-limit.js + env: + BASE_URL: http://localhost:3000 + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: k6-report + path: load-tests/ + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2496ac9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# dependencies +node_modules/ + +# env +.env +.env.local +.env.*.local + +# next +.next/ +out/ + +# prisma +prisma/*.db + +# misc +.DS_Store +*.pem +npm-debug.log* +.mypy_cache/ +# RPA 持久化(storageState,task-120) +.rpa/ + +# typescript +*.tsbuildinfo +next-env.d.ts + +# ide +.idea/ +.vscode/ + +# local dev runtime +.dev/ + +# test artifacts +playwright-report/ +test-results/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef90be3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine AS base + +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +CMD ["node", "server.js"] diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 0000000..14d2302 --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npx prisma generate + +ENV NODE_ENV=production + +CMD ["npm", "run", "worker:rpa"] diff --git a/__tests__/api/admin-markup-routes.test.ts b/__tests__/api/admin-markup-routes.test.ts new file mode 100644 index 0000000..1d11a24 --- /dev/null +++ b/__tests__/api/admin-markup-routes.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "@/app/api/admin/markup-configs/route"; +import { PUT } from "@/app/api/admin/markup-configs/[customer_id]/route"; +import { resetServiceTokenCache } from "@/modules/auth/service-token"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + markupConfig: { + findMany: vi.fn(), + upsert: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/prisma"; + +const ADMIN_HEADERS = { + authorization: "Bearer admin-jwt", + "x-auth-type": "admin", + "x-user-id": "admin_demo", + "x-user-role": "admin", +}; + +describe("admin markup-configs API", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetServiceTokenCache(); + process.env.HOST_SERVICE_TOKENS = JSON.stringify({ + "demo-host-token": { + customerId: "CUST_001", + permissions: ["pricing:markup:write"], + }, + }); + process.env.CUSTOMER_REGISTRY = "CUST_002,CUST_003"; + }); + + it("GET 返回注册客户合并配置", async () => { + vi.mocked(prisma.markupConfig.findMany).mockResolvedValue([ + { + customerId: "CUST_002", + markupType: "percent", + markupPercent: 10, + markupFixedAmount: null, + operatorId: "admin_demo", + remark: null, + updatedAt: new Date("2026-06-15T10:00:00Z"), + }, + ] as never); + + const res = await GET( + new Request("http://localhost/api/admin/markup-configs?page=1&size=10", { + headers: ADMIN_HEADERS, + }), + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.total).toBe(3); + expect(body.data.list[1].customer_id).toBe("CUST_002"); + expect(body.data.list[1].markup_percent).toBe(10); + }); + + it("PUT 固定金额加价成功", async () => { + vi.mocked(prisma.markupConfig.upsert).mockResolvedValue({ + customerId: "CUST_001", + markupType: "fixed", + markupPercent: 0, + markupFixedAmount: 25, + operatorId: "admin_demo", + remark: "测试", + updatedAt: new Date("2026-06-24T10:00:00Z"), + } as never); + + const res = await PUT( + new Request("http://localhost/api/admin/markup-configs/CUST_001", { + method: "PUT", + headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ + markup_type: "fixed", + markup_fixed_amount: 25, + remark: "测试", + }), + }), + { params: Promise.resolve({ customer_id: "CUST_001" }) }, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.markup_type).toBe("fixed"); + expect(body.data.markup_fixed_amount).toBe(25); + }); + + it("PUT 未知客户 400", async () => { + const res = await PUT( + new Request("http://localhost/api/admin/markup-configs/CUST_999", { + method: "PUT", + headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ markup_percent: 5 }), + }), + { params: Promise.resolve({ customer_id: "CUST_999" }) }, + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.message).toBe("客户不存在"); + }); +}); diff --git a/__tests__/api/admin-queues.test.ts b/__tests__/api/admin-queues.test.ts new file mode 100644 index 0000000..5d79a4d --- /dev/null +++ b/__tests__/api/admin-queues.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET as getQueueStats } from "@/app/api/admin/queues/stats/route"; +import { GET as getBullBoard } from "@/app/api/admin/queues/[[...slug]]/route"; + +vi.mock("@/workers/rpa/queue", () => ({ + getQuoteRpaQueue: vi.fn(), + getQuoteRpaQueueCounts: vi.fn().mockResolvedValue({ + waiting: 2, + active: 1, + delayed: 3, + failed: 4, + completed: 100, + }), +})); + +vi.mock("@/lib/bull-board/setup", () => ({ + getBullBoardHandler: vi.fn(() => async () => + new Response("Bull Board", { + status: 200, + headers: { "Content-Type": "text/html" }, + }), + ), +})); + +const ADMIN_HEADERS = { + "x-auth-type": "admin", + "x-user-id": "U_admin_demo", + "x-user-role": "admin", +}; + +const SERVICE_HEADERS = { + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "", +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("admin queues API(task-132)", () => { + it("GET /api/admin/queues/stats → 200 + counts", async () => { + const response = await getQueueStats( + new Request("http://localhost/api/admin/queues/stats", { + headers: ADMIN_HEADERS, + }), + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.code).toBe(0); + expect(json.data.queue_name).toBe("quote-rpa"); + expect(json.data.depth).toBe(6); + expect(json.data.failed).toBe(4); + expect(json.data.delayed).toBe(3); + }); + + it("GET /api/admin/queues/stats 非 admin → 403", async () => { + const response = await getQueueStats( + new Request("http://localhost/api/admin/queues/stats", { + headers: SERVICE_HEADERS, + }), + ); + const json = await response.json(); + + expect(response.status).toBe(403); + expect(json.code).toBe("FORBIDDEN"); + }); + + it("GET /api/admin/queues Bull Board admin → 200 HTML", async () => { + const response = await getBullBoard( + new Request("http://localhost/api/admin/queues", { + headers: ADMIN_HEADERS, + }), + { params: Promise.resolve({}) }, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toContain("Bull Board"); + }); + + it("GET /api/admin/queues 非 admin → 403", async () => { + const response = await getBullBoard( + new Request("http://localhost/api/admin/queues", { + headers: SERVICE_HEADERS, + }), + { params: Promise.resolve({}) }, + ); + const json = await response.json(); + + expect(response.status).toBe(403); + expect(json.code).toBe("FORBIDDEN"); + }); +}); diff --git a/__tests__/api/alerts-routes.test.ts b/__tests__/api/alerts-routes.test.ts new file mode 100644 index 0000000..9bd46a1 --- /dev/null +++ b/__tests__/api/alerts-routes.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET as listAlerts } from "@/app/api/alerts/route"; +import { POST as resolveAlert } from "@/app/api/alerts/[id]/resolve/route"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + alertLog: { + count: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/prisma"; + +const ADMIN_HEADERS = { + "x-auth-type": "admin", + "x-user-id": "U_admin_demo", + "x-user-role": "admin", +}; + +const SERVICE_HEADERS = { + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "", +}; + +const SAMPLE_ALERT = { + id: BigInt(1), + alertType: "RPA_FAILED", + quoteId: "QTE_001", + cargoHash: "abc123", + detailJson: { reason: "timeout" }, + status: "open", + resolverId: null, + resolvedAt: null, + createdAt: new Date("2026-06-16T08:00:00Z"), +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/alerts", () => { + it("admin + type 筛选 → 200", async () => { + vi.mocked(prisma.alertLog.count).mockResolvedValue(1); + vi.mocked(prisma.alertLog.findMany).mockResolvedValue([SAMPLE_ALERT as never]); + + const response = await listAlerts( + new Request( + "http://localhost/api/alerts?page=1&size=20&type=RPA_FAILED&status=open", + { headers: ADMIN_HEADERS }, + ), + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.code).toBe(0); + expect(json.data.list).toHaveLength(1); + expect(json.data.list[0].alert_type).toBe("RPA_FAILED"); + expect(prisma.alertLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { alertType: "RPA_FAILED", status: "open" }, + }), + ); + }); + + it("缺分页 → 400", async () => { + const response = await listAlerts( + new Request("http://localhost/api/alerts", { headers: ADMIN_HEADERS }), + ); + expect(response.status).toBe(400); + }); + + it("宿主 token → 403", async () => { + const response = await listAlerts( + new Request("http://localhost/api/alerts?page=1&size=20", { + headers: SERVICE_HEADERS, + }), + ); + expect(response.status).toBe(403); + }); +}); + +describe("POST /api/alerts/{id}/resolve", () => { + it("admin 处理 → 200 + resolved", async () => { + vi.mocked(prisma.alertLog.findUnique).mockResolvedValue( + SAMPLE_ALERT as never, + ); + vi.mocked(prisma.alertLog.update).mockResolvedValue({ + ...SAMPLE_ALERT, + status: "resolved", + resolverId: "U_admin_demo", + resolvedAt: new Date("2026-06-16T09:00:00Z"), + } as never); + + const response = await resolveAlert( + new Request("http://localhost/api/alerts/1/resolve", { + method: "POST", + headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ resolver_id: "U_admin_demo" }), + }), + { params: Promise.resolve({ id: "1" }) }, + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.data.status).toBe("resolved"); + expect(json.data.resolver_id).toBe("U_admin_demo"); + }); + + it("不存在 id → 404", async () => { + vi.mocked(prisma.alertLog.findUnique).mockResolvedValue(null); + + const response = await resolveAlert( + new Request("http://localhost/api/alerts/999/resolve", { + method: "POST", + headers: ADMIN_HEADERS, + }), + { params: Promise.resolve({ id: "999" }) }, + ); + + expect(response.status).toBe(404); + }); + + it("非 admin → 403", async () => { + const response = await resolveAlert( + new Request("http://localhost/api/alerts/1/resolve", { + method: "POST", + headers: SERVICE_HEADERS, + }), + { params: Promise.resolve({ id: "1" }) }, + ); + + expect(response.status).toBe(403); + }); +}); diff --git a/__tests__/api/cache-consistency.test.ts b/__tests__/api/cache-consistency.test.ts new file mode 100644 index 0000000..ca39036 --- /dev/null +++ b/__tests__/api/cache-consistency.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { detectDeviation } from "@/modules/alert/deviation-detector"; + +const prismaMock = vi.hoisted(() => ({ + quoteCacheMeta: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + alertLog: { + create: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ prisma: prismaMock })); + +const FOUR_TIERS = [ + { + service_level: "standard" as const, + rate_option: "lowest" as const, + carrier: "M", + transit_days: "5", + transit_description: "x", + raw_freight: 320, + surcharges: 0, + raw_total: 320, + }, + { + service_level: "standard" as const, + rate_option: "fastest" as const, + carrier: "M", + transit_days: "3", + transit_description: "x", + raw_freight: 365, + surcharges: 0, + raw_total: 365, + }, + { + service_level: "guaranteed" as const, + rate_option: "lowest" as const, + carrier: "M", + transit_days: "4", + transit_description: "x", + raw_freight: 380, + surcharges: 0, + raw_total: 380, + }, + { + service_level: "guaranteed" as const, + rate_option: "fastest" as const, + carrier: "M", + transit_days: "3", + transit_description: "x", + raw_freight: 410, + surcharges: 0, + raw_total: 410, + }, +]; + +describe("TC-401 cache vs RPA 一致性(task-113)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("L2 raw_total 与 RPA 写入一致;偏差≥5% 触发 PRICE_DEVIATION", async () => { + const l2RawTotal = 320; + prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({ + cargoHash: "hash_cache", + rawTotalStandard: l2RawTotal, + }); + prismaMock.quoteCacheMeta.update.mockResolvedValue({}); + prismaMock.alertLog.create.mockResolvedValue({}); + + const newRpaQuotes = FOUR_TIERS.map((t) => ({ ...t })); + newRpaQuotes[0].raw_total = 340; + newRpaQuotes[0].raw_freight = 340; + + await detectDeviation("hash_cache", newRpaQuotes, "QTE_CACHE"); + + expect(prismaMock.quoteCacheMeta.findUnique).toHaveBeenCalledWith({ + where: { cargoHash: "hash_cache" }, + }); + expect(prismaMock.alertLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ alertType: "PRICE_DEVIATION" }), + }), + ); + }); + + it("L2 与 RPA 一致(偏差<5%)→ 不触发预警", async () => { + prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({ + cargoHash: "hash_ok", + rawTotalStandard: 320, + }); + prismaMock.quoteCacheMeta.update.mockResolvedValue({}); + + await detectDeviation("hash_ok", FOUR_TIERS, "QTE_OK"); + + expect(prismaMock.alertLog.create).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/api/l2-expire.test.ts b/__tests__/api/l2-expire.test.ts new file mode 100644 index 0000000..bdaf0b1 --- /dev/null +++ b/__tests__/api/l2-expire.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { submitQuote } from "@/modules/quote/orchestrator"; + +vi.mock("@/modules/cache/redis-cache", () => ({ + getL2: vi.fn(), +})); + +vi.mock("@/modules/quote/idempotency", () => ({ + checkIdempotency: vi.fn(), + saveIdempotency: vi.fn(), +})); + +vi.mock("@/modules/quote/quote-id", () => ({ + generateQuoteId: vi.fn(), + handleQuoteIdConflict: vi.fn(), + isQuoteIdUniqueConflict: vi.fn(), +})); + +vi.mock("@/modules/quote/rpa-queue", () => ({ + enqueueQuoteJob: vi.fn(), +})); + +vi.mock("@/modules/pricing/engine", () => ({ + getMarkupPercent: vi.fn().mockResolvedValue(0), + applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes), +})); + +vi.mock("@/modules/metrics/collector", () => ({ + safeRecord: vi.fn(), + recordPostTotal: vi.fn().mockResolvedValue(undefined), + recordCacheHit: vi.fn().mockResolvedValue(undefined), + recordPostDone: vi.fn().mockResolvedValue(undefined), + recordRpaTriggered: vi.fn().mockResolvedValue(undefined), + markQuoteStarted: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { create: vi.fn() }, + }, +})); + +import { getL2 } from "@/modules/cache/redis-cache"; +import { checkIdempotency } from "@/modules/quote/idempotency"; +import { enqueueQuoteJob } from "@/modules/quote/rpa-queue"; +import { + MOTHERSHIP_DELIVERY_CONFIRMED, + MOTHERSHIP_PICKUP_CONFIRMED, +} from "@/__tests__/fixtures/mothership-address"; + +const VALID_BODY = { + request_id: "550e8400-e29b-41d4-a716-446655440000", + customer_id: "CUST_001", + pickup_address: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_PICKUP_CONFIRMED, + }, + delivery_address: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_DELIVERY_CONFIRMED, + }, + weight: { value: 500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + pallet_count: 2, + cargo_type: "general_freight", +}; + +describe("TC-108 L2 过期触发 RPA(task-112)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkIdempotency).mockResolvedValue({ hit: false }); + vi.mocked(getL2).mockResolvedValue(null); + vi.mocked(enqueueQuoteJob).mockResolvedValue(undefined); + }); + + it("L2 不存在(已过期/DEL)→ 触发 RPA 入队", async () => { + const result = await submitQuote(VALID_BODY); + + expect(result.status).toBe("processing"); + expect(enqueueQuoteJob).toHaveBeenCalledTimes(1); + expect(result.source_type).toBeUndefined(); + }); + + it("L2 存在 → 不走 RPA", async () => { + vi.mocked(getL2).mockResolvedValue({ + quotes: [ + { + service_level: "standard", + rate_option: "lowest", + raw_freight: 320, + surcharges: 0, + raw_total: 320, + carrier: "M", + transit_days: "5", + transit_description: "x", + }, + { + service_level: "standard", + rate_option: "fastest", + raw_freight: 365, + surcharges: 0, + raw_total: 365, + carrier: "M", + transit_days: "3", + transit_description: "x", + }, + { + service_level: "guaranteed", + rate_option: "lowest", + raw_freight: 380, + surcharges: 0, + raw_total: 380, + carrier: "M", + transit_days: "4", + transit_description: "x", + }, + { + service_level: "guaranteed", + rate_option: "fastest", + raw_freight: 410, + surcharges: 0, + raw_total: 410, + carrier: "M", + transit_days: "3", + transit_description: "x", + }, + ], + }); + + const result = await submitQuote(VALID_BODY); + + expect(result.status).toBe("done"); + expect(result.source_type).toBe("cache"); + expect(enqueueQuoteJob).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/api/metrics-dashboard-routes.test.ts b/__tests__/api/metrics-dashboard-routes.test.ts new file mode 100644 index 0000000..c318c36 --- /dev/null +++ b/__tests__/api/metrics-dashboard-routes.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET as getDashboard } from "@/app/api/metrics/dashboard/route"; + +vi.mock("@/modules/metrics/collector", () => ({ + getMetricsSnapshot: vi.fn(), +})); + +vi.mock("@/workers/rpa/queue", () => ({ + getQueueDepth: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + alertLog: { + count: vi.fn(), + }, + }, +})); + +import { getMetricsSnapshot } from "@/modules/metrics/collector"; +import { prisma } from "@/lib/prisma"; +import { getQueueDepth } from "@/workers/rpa/queue"; +import { METRIC_COUNTER } from "@/lib/constants/metrics"; + +const ADMIN_HEADERS = { + "x-auth-type": "admin", + "x-user-id": "U_admin_demo", + "x-user-role": "admin", +}; + +const SERVICE_HEADERS = { + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "", +}; + +const SAMPLE_SNAPSHOT = { + api_success_rate: 99.5, + rpa_success_rate: 85, + realtime_rate: 90, + cache_hit_rate: 92, + p95_latency_ms: 12_000, + latency_sample_count: 100, + counters: { + [METRIC_COUNTER.POST_TOTAL]: 1000, + [METRIC_COUNTER.POST_DONE]: 995, + [METRIC_COUNTER.CACHE_HIT_L1]: 200, + [METRIC_COUNTER.CACHE_HIT_L2]: 720, + [METRIC_COUNTER.RPA_TRIGGERED]: 80, + [METRIC_COUNTER.RPA_DONE]: 68, + [METRIC_COUNTER.DONE_TOTAL]: 995, + [METRIC_COUNTER.DONE_REALTIME]: 896, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/metrics/dashboard", () => { + it("admin → 200 + GD-4 四指标", async () => { + vi.mocked(getMetricsSnapshot).mockResolvedValue(SAMPLE_SNAPSHOT); + vi.mocked(getQueueDepth).mockResolvedValue(3); + vi.mocked(prisma.alertLog.count).mockResolvedValue(5); + + const response = await getDashboard( + new Request("http://localhost/api/metrics/dashboard", { + headers: ADMIN_HEADERS, + }), + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.code).toBe(0); + expect(json.data.api_success_rate).toBe(99.5); + expect(json.data.rpa_success_rate).toBe(85); + expect(json.data.realtime_rate).toBe(90); + expect(json.data.cache_hit_rate).toBe(92); + expect(json.data.p95_latency_ms).toBe(12_000); + expect(json.data.queue_depth).toBe(3); + expect(json.data.open_alerts_count).toBe(5); + }); + + it("非 admin → 403", async () => { + const response = await getDashboard( + new Request("http://localhost/api/metrics/dashboard", { + headers: SERVICE_HEADERS, + }), + ); + + expect(response.status).toBe(403); + expect(getMetricsSnapshot).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/api/quotes-routes.test.ts b/__tests__/api/quotes-routes.test.ts new file mode 100644 index 0000000..2902eee --- /dev/null +++ b/__tests__/api/quotes-routes.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { POST } from "@/app/api/quotes/route"; +import { GET as getQuote } from "@/app/api/quotes/[quote_id]/route"; +import { GET as getHistory } from "@/app/api/quotes/history/route"; +import { PUT as putMarkup } from "@/app/api/markup-configs/[customer_id]/route"; + +vi.mock("@/modules/quote/orchestrator", () => ({ + submitQuote: vi.fn(), +})); + +vi.mock("@/lib/api/rate-limit", () => ({ + enforceRateLimits: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { + findFirst: vi.fn(), + count: vi.fn(), + findMany: vi.fn(), + }, + markupConfig: { upsert: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + alertLog: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +import { submitQuote } from "@/modules/quote/orchestrator"; +import { prisma } from "@/lib/prisma"; +import { resetServiceTokenCache } from "@/modules/auth/service-token"; +import { + MOTHERSHIP_DELIVERY_CONFIRMED, + MOTHERSHIP_PICKUP_CONFIRMED, +} from "@/__tests__/fixtures/mothership-address"; + +const SERVICE_HEADERS = { + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "pricing:markup:write", +}; + +const VALID_BODY = { + request_id: "550e8400-e29b-41d4-a716-446655440000", + customer_id: "CUST_001", + pickup_address: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_PICKUP_CONFIRMED, + }, + delivery_address: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_DELIVERY_CONFIRMED, + }, + weight: { value: 500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + pallet_count: 2, + cargo_type: "general_freight", +}; + +beforeEach(() => { + vi.clearAllMocks(); + resetServiceTokenCache(); + process.env.HOST_SERVICE_TOKENS = JSON.stringify({ + "demo-host-token": { + customerId: "CUST_001", + permissions: ["pricing:markup:write"], + }, + }); +}); + +describe("POST /api/quotes", () => { + it("正常询价 → 200 + quote_id", async () => { + vi.mocked(submitQuote).mockResolvedValue({ + quote_id: "QTE_001", + status: "done", + source_type: "cache", + }); + + const response = await POST( + new Request("http://localhost/api/quotes", { + method: "POST", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify(VALID_BODY), + }), + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.code).toBe(0); + expect(json.data.quote_id).toBe("QTE_001"); + expect(json.data.source_type).toBe("cache"); + }); + + it("customer_id 不匹配 → 403", async () => { + const response = await POST( + new Request("http://localhost/api/quotes", { + method: "POST", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify({ ...VALID_BODY, customer_id: "CUST_999" }), + }), + ); + expect(response.status).toBe(403); + }); +}); + +describe("GET /api/quotes/{quote_id}", () => { + it("他人 quote_id → 403(TC-501)", async () => { + vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue({ + quoteId: "QTE_B", + customerId: "CUST_B", + requestId: "req", + status: "done", + currency: "USD", + isRealtime: true, + sourceType: "cache", + confidenceScore: null, + validUntil: new Date(Date.now() + 60_000), + quotesJson: [], + errorCode: null, + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const response = await getQuote( + new Request("http://localhost/api/quotes/QTE_B", { + headers: SERVICE_HEADERS, + }), + { params: Promise.resolve({ quote_id: "QTE_B" }) }, + ); + expect(response.status).toBe(403); + }); + + it("不存在 → 404", async () => { + vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue(null); + + const response = await getQuote( + new Request("http://localhost/api/quotes/QTE_X", { + headers: SERVICE_HEADERS, + }), + { params: Promise.resolve({ quote_id: "QTE_X" }) }, + ); + expect(response.status).toBe(404); + }); +}); + +describe("GET /api/quotes/history", () => { + it("缺 page/size → 400(TC-506)", async () => { + const response = await getHistory( + new Request("http://localhost/api/quotes/history", { + headers: SERVICE_HEADERS, + }), + ); + expect(response.status).toBe(400); + }); +}); + +describe("PUT /api/markup-configs/{customer_id}", () => { + it("markup=30.1 → 400(TC-505)", async () => { + const response = await putMarkup( + new Request("http://localhost/api/markup-configs/CUST_001", { + method: "PUT", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify({ markup_percent: 30.1, operator_id: "op1" }), + }), + { params: Promise.resolve({ customer_id: "CUST_001" }) }, + ); + expect(response.status).toBe(400); + }); + + it("无 pricing:markup:write → 403(TC-502)", async () => { + const response = await putMarkup( + new Request("http://localhost/api/markup-configs/CUST_001", { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "", + }, + body: JSON.stringify({ markup_percent: 10, operator_id: "op1" }), + }), + { params: Promise.resolve({ customer_id: "CUST_001" }) }, + ); + expect(response.status).toBe(403); + }); +}); diff --git a/__tests__/api/quotes.integration.test.ts b/__tests__/api/quotes.integration.test.ts new file mode 100644 index 0000000..a5958d3 --- /dev/null +++ b/__tests__/api/quotes.integration.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { POST } from "@/app/api/quotes/route"; + +vi.mock("@/modules/cache/redis-cache", () => ({ + getL2: vi.fn(), + setL2: vi.fn(), + setL3: vi.fn(), +})); + +vi.mock("@/modules/quote/idempotency", () => ({ + checkIdempotency: vi.fn(), + saveIdempotency: vi.fn(), +})); + +vi.mock("@/modules/quote/quote-id", () => ({ + generateQuoteId: vi.fn(), + handleQuoteIdConflict: vi.fn(), + isQuoteIdUniqueConflict: vi.fn(), +})); + +vi.mock("@/modules/quote/rpa-queue", () => ({ + enqueueQuoteJob: vi.fn(), +})); + +vi.mock("@/modules/metrics/collector", () => ({ + safeRecord: vi.fn(), + recordPostTotal: vi.fn().mockResolvedValue(undefined), + recordCacheHit: vi.fn().mockResolvedValue(undefined), + recordPostDone: vi.fn().mockResolvedValue(undefined), + recordRpaTriggered: vi.fn().mockResolvedValue(undefined), + markQuoteStarted: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/api/rate-limit", () => ({ + enforceRateLimits: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { create: vi.fn() }, + markupConfig: { findFirst: vi.fn().mockResolvedValue(null) }, + }, +})); + +import { getL2 } from "@/modules/cache/redis-cache"; +import { checkIdempotency } from "@/modules/quote/idempotency"; +import { generateQuoteId } from "@/modules/quote/quote-id"; +import { enqueueQuoteJob } from "@/modules/quote/rpa-queue"; +import { prisma } from "@/lib/prisma"; +import { resetServiceTokenCache } from "@/modules/auth/service-token"; +import { buildCargoHash, serializeAddress } from "@/modules/quote/cargo-hash"; +import { + MOTHERSHIP_DELIVERY_CONFIRMED, + MOTHERSHIP_PICKUP_CONFIRMED, +} from "@/__tests__/fixtures/mothership-address"; + +const SERVICE_HEADERS = { + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "pricing:markup:write", +}; + +const VALID_BODY = { + request_id: "550e8400-e29b-41d4-a716-446655440000", + customer_id: "CUST_001", + pickup_address: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_PICKUP_CONFIRMED, + }, + delivery_address: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_DELIVERY_CONFIRMED, + }, + weight: { value: 500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + pallet_count: 2, + cargo_type: "general_freight", +}; + +const FOUR_TIERS = [ + { + service_level: "standard", + rate_option: "lowest", + raw_freight: 320, + surcharges: 0, + raw_total: 320, + carrier: "M", + transit_days: "5", + transit_description: "x", + }, + { + service_level: "standard", + rate_option: "fastest", + raw_freight: 365, + surcharges: 0, + raw_total: 365, + carrier: "M", + transit_days: "3", + transit_description: "x", + }, + { + service_level: "guaranteed", + rate_option: "lowest", + raw_freight: 380, + surcharges: 0, + raw_total: 380, + carrier: "M", + transit_days: "4", + transit_description: "x", + }, + { + service_level: "guaranteed", + rate_option: "fastest", + raw_freight: 410, + surcharges: 0, + raw_total: 410, + carrier: "M", + transit_days: "3", + transit_description: "x", + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + resetServiceTokenCache(); + process.env.HOST_SERVICE_TOKENS = JSON.stringify({ + "demo-host-token": { + customerId: "CUST_001", + permissions: ["pricing:markup:write"], + }, + }); + vi.mocked(checkIdempotency).mockResolvedValue({ hit: false }); + vi.mocked(getL2).mockResolvedValue(null); + vi.mocked(generateQuoteId).mockResolvedValue("QTE_INT_0001"); + vi.mocked(prisma.quoteRecord.create).mockResolvedValue({} as never); + vi.mocked(enqueueQuoteJob).mockResolvedValue(undefined); +}); + +describe("POST /api/quotes 集成(task-085)", () => { + it("TC-101:首次询价 → processing + 入队 RPA", async () => { + const response = await POST( + new Request("http://localhost/api/quotes", { + method: "POST", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify(VALID_BODY), + }), + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.data.status).toBe("processing"); + expect(enqueueQuoteJob).toHaveBeenCalled(); + }); + + it("TC-104:L2 命中 → done + source=cache", async () => { + vi.mocked(getL2).mockResolvedValue({ quotes: FOUR_TIERS }); + + const response = await POST( + new Request("http://localhost/api/quotes", { + method: "POST", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify(VALID_BODY), + }), + ); + const json = await response.json(); + + expect(json.data.status).toBe("done"); + expect(json.data.source_type).toBe("cache"); + expect(enqueueQuoteJob).not.toHaveBeenCalled(); + }); + + it("TC-105:L1 幂等 → 返回原 quote_id", async () => { + vi.mocked(checkIdempotency).mockResolvedValue({ + hit: true, + quoteId: "QTE_EXISTING", + response: { + quote_id: "QTE_EXISTING", + status: "done", + source_type: "rpa", + }, + }); + + const response = await POST( + new Request("http://localhost/api/quotes", { + method: "POST", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify(VALID_BODY), + }), + ); + const json = await response.json(); + + expect(json.data.quote_id).toBe("QTE_EXISTING"); + expect(prisma.quoteRecord.create).not.toHaveBeenCalled(); + }); + + it("TC-106/107:相同货物 hash 一致;service_level 不影响 hash", () => { + const pickup = serializeAddress({ + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + }); + const delivery = serializeAddress({ + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + }); + const base = { + pickupSerialized: pickup, + deliverySerialized: delivery, + weightLb: 500, + dimLIn: 48, + dimWIn: 40, + dimHIn: 48, + palletCount: 2, + cargoType: "general_freight", + }; + const h1 = buildCargoHash(base); + const h2 = buildCargoHash({ ...base, cargoType: "general_freight" }); + expect(h1).toBe(h2); + expect(h1).toHaveLength(32); + }); +}); diff --git a/__tests__/api/rpa-admin.test.ts b/__tests__/api/rpa-admin.test.ts new file mode 100644 index 0000000..01d6690 --- /dev/null +++ b/__tests__/api/rpa-admin.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET as getRpaStatus } from "@/app/api/rpa/status/route"; +import { POST as pauseWorkerRoute } from "@/app/api/rpa/worker/pause/route"; + +vi.mock("@/modules/cache/circuit-breaker", () => ({ + isCircuitOpen: vi.fn().mockResolvedValue(false), +})); + +vi.mock("@/workers/rpa/queue", () => ({ + getQueueDepth: vi.fn().mockResolvedValue(3), +})); + +vi.mock("@/workers/rpa/worker-state", () => ({ + listWorkerHeartbeats: vi.fn().mockResolvedValue([ + { workerId: "worker-1", lastSeenMs: Date.now() }, + ]), + getWorkerPauseRemaining: vi.fn().mockResolvedValue(0), + pauseWorker: vi.fn().mockResolvedValue(undefined), + isWorkerPaused: vi.fn().mockResolvedValue(false), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { + count: vi.fn().mockResolvedValue(10), + }, + }, +})); + +import { pauseWorker } from "@/workers/rpa/worker-state"; + +const ADMIN_HEADERS = { + "x-auth-type": "admin", + "x-user-id": "U_admin_demo", + "x-user-role": "admin", +}; + +const SERVICE_HEADERS = { + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "", +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("RPA 管理 API 集成(task-116)", () => { + it("GET /api/rpa/status → 200 + workers", async () => { + const response = await getRpaStatus( + new Request("http://localhost/api/rpa/status", { + headers: ADMIN_HEADERS, + }), + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.code).toBe(0); + expect(json.data.workers).toHaveLength(1); + expect(json.data.queue_depth).toBe(3); + }); + + it("POST pause → Worker 暂停", async () => { + const response = await pauseWorkerRoute( + new Request("http://localhost/api/rpa/worker/pause", { + method: "POST", + headers: { + ...ADMIN_HEADERS, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + worker_id: "worker-1", + duration_minutes: 5, + }), + }), + ); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.code).toBe(0); + expect(pauseWorker).toHaveBeenCalledWith("worker-1", 5); + }); + + it("非 admin → 403", async () => { + const response = await getRpaStatus( + new Request("http://localhost/api/rpa/status", { + headers: SERVICE_HEADERS, + }), + ); + expect(response.status).toBe(403); + }); +}); diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts new file mode 100644 index 0000000..2a14ce7 --- /dev/null +++ b/__tests__/api/security.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { POST } from "@/app/api/quotes/route"; +import { GET as getQuote } from "@/app/api/quotes/[quote_id]/route"; +import { PUT as putMarkup } from "@/app/api/markup-configs/[customer_id]/route"; + +vi.mock("@/modules/quote/orchestrator", () => ({ + submitQuote: vi.fn(), +})); + +vi.mock("@/lib/api/rate-limit", () => ({ + enforceRateLimits: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { findFirst: vi.fn() }, + auditLog: { create: vi.fn() }, + markupConfig: { upsert: vi.fn() }, + }, +})); + +vi.mock("@/modules/alert/service", () => ({ + writeAlert: vi.fn(), +})); + +import { submitQuote } from "@/modules/quote/orchestrator"; +import { enforceRateLimits } from "@/lib/api/rate-limit"; +import { prisma } from "@/lib/prisma"; +import { writeAlert } from "@/modules/alert/service"; +import { resetServiceTokenCache } from "@/modules/auth/service-token"; +import { fail } from "@/lib/response"; +import { + MOTHERSHIP_DELIVERY_CONFIRMED, + MOTHERSHIP_PICKUP_CONFIRMED, +} from "@/__tests__/fixtures/mothership-address"; + +const SERVICE_HEADERS = { + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "pricing:markup:write", +}; + +const VALID_BODY = { + request_id: "550e8400-e29b-41d4-a716-446655440000", + customer_id: "CUST_001", + pickup_address: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_PICKUP_CONFIRMED, + }, + delivery_address: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_DELIVERY_CONFIRMED, + }, + weight: { value: 500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + pallet_count: 2, + cargo_type: "general_freight", +}; + +beforeEach(() => { + vi.clearAllMocks(); + resetServiceTokenCache(); + process.env.HOST_SERVICE_TOKENS = JSON.stringify({ + "demo-host-token": { + customerId: "CUST_001", + permissions: ["pricing:markup:write"], + }, + }); + vi.mocked(enforceRateLimits).mockResolvedValue(null); + vi.mocked(submitQuote).mockResolvedValue({ + quote_id: "QTE_001", + status: "done", + source_type: "cache", + }); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.markupConfig.upsert).mockResolvedValue({} as never); +}); + +describe("安全与权限集成(task-086)", () => { + it("TC-501:越权查询他人 quote → 403 + audit", async () => { + vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue({ + quoteId: "QTE_B", + customerId: "CUST_B", + requestId: "req", + status: "done", + currency: "USD", + isRealtime: true, + sourceType: "cache", + confidenceScore: null, + validUntil: new Date(Date.now() + 60_000), + quotesJson: [], + errorCode: null, + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const response = await getQuote( + new Request("http://localhost/api/quotes/QTE_B", { + headers: SERVICE_HEADERS, + }), + { params: Promise.resolve({ quote_id: "QTE_B" }) }, + ); + + expect(response.status).toBe(403); + expect(prisma.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ action: "FORBIDDEN_ACCESS" }), + }), + ); + }); + + it("TC-502:无 pricing:markup:write → 403", async () => { + const response = await putMarkup( + new Request("http://localhost/api/markup-configs/CUST_001", { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-auth-type": "service", + "x-customer-id": "CUST_001", + "x-permissions": "", + }, + body: JSON.stringify({ markup_percent: 10, operator_id: "op1" }), + }), + { params: Promise.resolve({ customer_id: "CUST_001" }) }, + ); + expect(response.status).toBe(403); + }); + + it("TC-503:customer 限流 → 429", async () => { + vi.mocked(enforceRateLimits).mockResolvedValue( + fail("RATE_LIMITED", "请求过于频繁,请稍后再试", 429), + ); + + const response = await POST( + new Request("http://localhost/api/quotes", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...SERVICE_HEADERS, + "x-forwarded-for": "10.0.0.1", + }, + body: JSON.stringify(VALID_BODY), + }), + ); + expect(response.status).toBe(429); + }); + + it("TC-504:SQL 注入 street → 400 + SECURITY 预警", async () => { + const { SecurityValidationError } = await import("@/modules/quote/types"); + vi.mocked(submitQuote).mockImplementation(() => { + throw new SecurityValidationError("提货地址无效,请检查"); + }); + + const response = await POST( + new Request("http://localhost/api/quotes", { + method: "POST", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify({ + ...VALID_BODY, + pickup_address: { + ...VALID_BODY.pickup_address, + street: "'; DROP TABLE quote_record; --", + }, + }), + }), + ); + + expect(response.status).toBe(400); + await vi.waitFor(() => { + expect(writeAlert).toHaveBeenCalledWith( + "SECURITY", + expect.any(Object), + ); + }); + }); + + it("TC-505:markup=30.1 → 400", async () => { + const response = await putMarkup( + new Request("http://localhost/api/markup-configs/CUST_001", { + method: "PUT", + headers: { "Content-Type": "application/json", ...SERVICE_HEADERS }, + body: JSON.stringify({ markup_percent: 30.1, operator_id: "op1" }), + }), + { params: Promise.resolve({ customer_id: "CUST_001" }) }, + ); + expect(response.status).toBe(400); + }); +}); diff --git a/__tests__/fixtures/axel-quote.ts b/__tests__/fixtures/axel-quote.ts new file mode 100644 index 0000000..8f75e4f --- /dev/null +++ b/__tests__/fixtures/axel-quote.ts @@ -0,0 +1,46 @@ +/** 阶段0 录证 axel/quote 响应精简 fixture */ +export const AXEL_QUOTE_BODY = { + data: { + rates: { + standard: { + lowest: { + price: 423.49, + finalPrice: 428.48, + days: 8, + serviceLevel: "standard", + serviceType: "lowest", + serviceCommitmentDescription: "Standard", + }, + fastest: { + price: 662.08, + finalPrice: 667.07, + days: 3, + serviceLevel: "standard", + serviceType: "fastest", + serviceCommitmentDescription: "LTL Standard", + }, + bestValue: { + price: 576.24, + days: 6, + serviceLevel: "standard", + serviceType: "bestValue", + }, + }, + guaranteed: { + bestValue: { + price: 809.16, + finalPrice: 814.15, + days: 3, + serviceLevel: "guaranteed", + serviceType: "bestValue", + serviceCommitmentDescription: "Urgent Care (Guaranteed by 5pm)", + }, + }, + }, + availableRates: {}, + }, + key: "quote_test", + metadata: {}, +}; + +export const AXEL_QUOTE_URL = "https://services.mothership.com/axel/quote"; diff --git a/__tests__/fixtures/mothership-address.ts b/__tests__/fixtures/mothership-address.ts new file mode 100644 index 0000000..c0e71ec --- /dev/null +++ b/__tests__/fixtures/mothership-address.ts @@ -0,0 +1,12 @@ +/** 测试用:用户已确认的 MotherShip 精确地址字段 */ +export const MOTHERSHIP_PICKUP_CONFIRMED = { + mothership_option_id: "ms_test_pickup", + mothership_display_label: "1234 Warehouse Blvd, Los Angeles, CA 90001", + selected_from_mothership: true as const, +}; + +export const MOTHERSHIP_DELIVERY_CONFIRMED = { + mothership_option_id: "ms_test_delivery", + mothership_display_label: "5678 Distribution Dr, Dallas, TX 75201", + selected_from_mothership: true as const, +}; diff --git a/__tests__/hooks/use-quote-polling.test.ts b/__tests__/hooks/use-quote-polling.test.ts new file mode 100644 index 0000000..1ccebd2 --- /dev/null +++ b/__tests__/hooks/use-quote-polling.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { pollQuoteUntilDone } from "@/hooks/use-quote-polling"; +import { + POLL_INTERVAL_MS, + POLL_TIMEOUT_MS, +} from "@/lib/frontend/constants"; +import type { QuoteDetail } from "@/lib/frontend/types"; + +function processingQuote(): QuoteDetail { + return { + quote_id: "QTE_test", + request_id: "req-1", + status: "processing", + currency: "USD", + }; +} + +function doneQuote(): QuoteDetail { + return { + quote_id: "QTE_test", + request_id: "req-1", + status: "done", + currency: "USD", + is_realtime: true, + quotes: [], + }; +} + +describe("pollQuoteUntilDone", () => { + it("前 3 次网络失败第 4 次成功 → done", async () => { + let calls = 0; + const result = await pollQuoteUntilDone(async () => { + calls += 1; + if (calls < 4) { + return { ok: false }; + } + return { ok: true, data: doneQuote() }; + }); + expect(result.type).toBe("done"); + if (result.type === "done") { + expect(result.quote.status).toBe("done"); + } + expect(calls).toBe(4); + }); + + it("超过 POLL_TIMEOUT_MS 仍 processing → timeout", async () => { + vi.useFakeTimers(); + const promise = pollQuoteUntilDone(async () => ({ + ok: true, + data: processingQuote(), + })); + + await vi.advanceTimersByTimeAsync( + POLL_TIMEOUT_MS + POLL_INTERVAL_MS, + ); + const result = await promise; + expect(result.type).toBe("timeout"); + vi.useRealTimers(); + }); + + it("failed 状态 → error 含可读文案", async () => { + const result = await pollQuoteUntilDone(async () => ({ + ok: true, + data: { + ...doneQuote(), + status: "failed", + error_code: "ADDRESS_SUGGESTION_NOT_FOUND", + error_message: "未找到用户确认的 MotherShip 地址", + }, + })); + expect(result.type).toBe("error"); + if (result.type === "error") { + expect(result.message).toBe("未找到用户确认的 MotherShip 地址"); + } + }); + + it("processing 超过 10s 后使用更短轮询间隔", async () => { + vi.useFakeTimers(); + let calls = 0; + const intervals: number[] = []; + let last = Date.now(); + + const promise = pollQuoteUntilDone(async () => { + const now = Date.now(); + if (calls > 0) { + intervals.push(now - last); + } + last = now; + calls += 1; + if (calls < 7) { + return { ok: true, data: processingQuote() }; + } + return { ok: true, data: doneQuote() }; + }); + + for (let i = 0; i < 6; i += 1) { + await vi.advanceTimersByTimeAsync(2_000); + } + await vi.advanceTimersByTimeAsync(800); + const result = await promise; + expect(result.type).toBe("done"); + expect(intervals.some((gap) => gap <= 800)).toBe(true); + vi.useRealTimers(); + }); +}); diff --git a/__tests__/integration/rpa-probe.test.ts b/__tests__/integration/rpa-probe.test.ts new file mode 100644 index 0000000..2ba9cf4 --- /dev/null +++ b/__tests__/integration/rpa-probe.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { validateProbeItems } from "@/lib/rpa/probe-validation"; +import { executeProbeQuote } from "@/lib/rpa/run-probe"; +import type { QuoteItem } from "@/modules/providers/quote-provider"; + +function fourTiers(): QuoteItem[] { + return [ + { + serviceLevel: "standard", + rateOption: "lowest", + carrier: "M", + transitDays: "5", + transitDescription: "最低", + rawFreight: 320, + surcharges: 0, + rawTotal: 320, + }, + { + serviceLevel: "standard", + rateOption: "fastest", + carrier: "M", + transitDays: "3", + transitDescription: "最快", + rawFreight: 365, + surcharges: 0, + rawTotal: 365, + }, + { + serviceLevel: "guaranteed", + rateOption: "lowest", + carrier: "M", + transitDays: "4", + transitDescription: "保价最低", + rawFreight: 380, + surcharges: 0, + rawTotal: 380, + }, + { + serviceLevel: "guaranteed", + rateOption: "fastest", + carrier: "M", + transitDays: "2", + transitDescription: "保价最快", + rawFreight: 410, + surcharges: 0, + rawTotal: 410, + }, + ]; +} + +describe("TC-601 probe 校验(task-133)", () => { + it("validateProbeItems:4 档通过", () => { + expect(validateProbeItems(fourTiers())).toEqual({ ok: true }); + }); + + it("validateProbeItems:仅 2 档 lowest → 通过(fastest 复用)", () => { + const twoLowest = [ + fourTiers()[0], + fourTiers()[2], + ]; + expect(validateProbeItems(twoLowest)).toEqual({ ok: true }); + }); + + it("validateProbeItems:仅 1 档 → 失败", () => { + const result = validateProbeItems([fourTiers()[0]]); + expect(result.ok).toBe(false); + }); + + it("mock 模式 executeProbeQuote → items.length===4", async () => { + process.env.RPA_MOCK_MODE = "true"; + const result = await executeProbeQuote(); + expect(result.items).toHaveLength(4); + expect(validateProbeItems(result.items)).toEqual({ ok: true }); + result.items.forEach((item) => { + expect(item.rawFreight).toBeGreaterThan(0); + expect(item.transitDescription).toBeTruthy(); + }); + }); +}); + +describe("probe 3/3 模拟(task-133)", () => { + beforeEach(() => { + process.env.RPA_MOCK_MODE = "true"; + }); + + it("连续 3 次 mock probe 均四档齐全", async () => { + for (let i = 0; i < 3; i++) { + const result = await executeProbeQuote(); + expect(result.items.length).toBe(4); + expect(validateProbeItems(result.items).ok).toBe(true); + } + }); +}); diff --git a/__tests__/lib/axel/candidates.test.ts b/__tests__/lib/axel/candidates.test.ts new file mode 100644 index 0000000..01e47de --- /dev/null +++ b/__tests__/lib/axel/candidates.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { axelSearchRowToCandidate } from "@/lib/axel/candidates"; + +describe("axelSearchRowToCandidate", () => { + it("display_label 与 axel description 一致,option_id 保留原始 placeId", () => { + const row = { + placeId: "ChIJ4cLf10jGwoARBQWdZHer2OQ", + description: "123 Main Street, Los Angeles, CA, USA", + mainText: "123 Main Street", + secondaryText: "Los Angeles, CA, USA", + }; + const candidate = axelSearchRowToCandidate(row, { + street: "123 Main St", + city: "Los Angeles", + state: "CA", + zip: "90001", + }); + expect(candidate?.option_id).toBe("ChIJ4cLf10jGwoARBQWdZHer2OQ"); + expect(candidate?.display_label).toBe( + "123 Main Street, Los Angeles, CA, USA", + ); + expect(candidate?.street).toContain("123"); + }); +}); diff --git a/__tests__/lib/axel/quote.test.ts b/__tests__/lib/axel/quote.test.ts new file mode 100644 index 0000000..ba74ae6 --- /dev/null +++ b/__tests__/lib/axel/quote.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { pickBestSearchCandidate } from "@/lib/axel/pick-candidate"; +import { buildAxelShipmentPayload } from "@/lib/axel/shipment"; +import { parseQuoteCliArgs } from "@/lib/axel/parse-cli-args"; + +describe("parseQuoteCliArgs", () => { + it("解析必填参数", () => { + const args = parseQuoteCliArgs([ + "--origin", + "3131 Western Ave, Seattle, WA", + "--dest", + "123 Main St, Los Angeles, CA", + "--pallets", + "2", + "--weight", + "500", + ]); + expect(args.origin).toContain("Seattle"); + expect(args.pallets).toBe(2); + expect(args.weightLb).toBe(500); + }); + + it("缺少 origin 时抛错", () => { + expect(() => + parseQuoteCliArgs(["--dest", "x", "--pallets", "1", "--weight", "100"]), + ).toThrow(/--origin/); + }); +}); + +describe("pickBestSearchCandidate", () => { + it("优先匹配门牌号", () => { + const picked = pickBestSearchCandidate("3131 Western Ave, Seattle, WA", [ + { + placeId: "ChIJ-other", + description: "Western Avenue, Seattle, WA, USA", + mainText: "Western Avenue", + }, + { + placeId: "ChIJ-good", + description: "3131 Western Avenue, Seattle, WA, USA", + mainText: "3131 Western Avenue", + }, + ]); + expect(picked?.placeId).toBe("ChIJ-good"); + }); + + it("门牌号查询不选泛化 route", () => { + const picked = pickBestSearchCandidate( + "123 Main St, Los Angeles, CA 90001", + [ + { + placeId: "ChIJ-good", + description: "123 Main Street, Los Angeles, CA, USA", + mainText: "123 Main Street", + }, + { + placeId: "Eh1-route", + description: "Main St, Los Angeles, CA, USA", + mainText: "Main St", + types: ["geocode", "route"], + }, + ], + ); + expect(picked?.placeId).toBe("ChIJ-good"); + }); +}); + +describe("buildAxelShipmentPayload", () => { + it("生成 shipment 含 cargo 与 autocompleteResult", () => { + const shipment = buildAxelShipmentPayload({ + pickup: { placeId: "ChIJ-pickup", street: "1 Main" }, + delivery: { placeId: "ChIJ-delivery", street: "2 Oak" }, + pickupDescription: "1 Main St, Seattle, WA", + deliveryDescription: "2 Oak Ave, Miami, FL", + cargo: { + palletCount: 2, + weightLb: 500, + lengthIn: 48, + widthIn: 40, + heightIn: 45, + }, + }); + expect(shipment.pickupDate).toMatch(/T19:00:00\.000Z$/); + const pickup = shipment.pickupLocation as Record; + expect(pickup.autocompleteResult).toBeTruthy(); + expect(Object.keys(shipment.cargo as object)).toHaveLength(1); + }); +}); diff --git a/__tests__/lib/frontend/format-address.test.ts b/__tests__/lib/frontend/format-address.test.ts new file mode 100644 index 0000000..ffedf15 --- /dev/null +++ b/__tests__/lib/frontend/format-address.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { formatAddressDisplayLabel } from "@/lib/frontend/format-address"; + +describe("formatAddressDisplayLabel", () => { + it("优先使用 street/city/state 组合,不含邮编", () => { + expect( + formatAddressDisplayLabel({ + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001", + }), + ).toBe("1234 Warehouse Blvd, Los Angeles, CA"); + }); + + it("从 formatted_address 剥离邮编与 USA", () => { + expect( + formatAddressDisplayLabel({ + formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA", + }), + ).toBe("5678 Distribution Dr, Dallas, TX"); + }); + + it("MotherShip 确认后展示与弹窗一致的 display_label", () => { + expect( + formatAddressDisplayLabel({ + street: "3131 Western Ave", + city: "Seattle", + state: "WA", + selected_from_mothership: true, + mothership_display_label: + "3131 Western Avenue, Seattle, WA, USA", + }), + ).toBe("3131 Western Avenue, Seattle, WA"); + }); +}); diff --git a/__tests__/lib/gateway-retry.test.ts b/__tests__/lib/gateway-retry.test.ts new file mode 100644 index 0000000..6527ee0 --- /dev/null +++ b/__tests__/lib/gateway-retry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeWithGatewayRetry } from "@/lib/gateway-retry"; + +describe("lib/gateway-retry", () => { + it("首次 503 第二次 200 返回 200", async () => { + const handler = vi + .fn() + .mockResolvedValueOnce({ status: 503, body: { error: "down" } }) + .mockResolvedValueOnce({ status: 200, body: { ok: true } }); + + const result = await executeWithGatewayRetry(handler); + expect(result.status).toBe(200); + expect(result.body).toEqual({ ok: true }); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("连续 503 返回 INTERNAL_ERROR", async () => { + const handler = vi.fn().mockResolvedValue({ status: 503, body: null }); + + const result = await executeWithGatewayRetry(handler); + expect(result.status).toBe(503); + expect(result.body).toEqual({ + code: "INTERNAL_ERROR", + message: "服务暂时不可用,请稍后重试", + data: null, + }); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("首次 200 不重试", async () => { + const handler = vi.fn().mockResolvedValue({ status: 200, body: { ok: true } }); + + const result = await executeWithGatewayRetry(handler); + expect(result.status).toBe(200); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/lib/math.test.ts b/__tests__/lib/math.test.ts new file mode 100644 index 0000000..2c8a849 --- /dev/null +++ b/__tests__/lib/math.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { roundHalfUp } from "@/lib/math"; + +describe("roundHalfUp", () => { + it("0.105×2 → 0.21(不是 0.20)", () => { + expect(roundHalfUp(0.105 * 2, 2)).toBe(0.21); + }); + + it("32.005 → 32.01", () => { + expect(roundHalfUp(32.005, 2)).toBe(32.01); + }); + + it("负数边界", () => { + expect(roundHalfUp(-1.005, 2)).toBe(-1.0); + expect(roundHalfUp(-1.006, 2)).toBe(-1.01); + }); + + it("整数精度", () => { + expect(roundHalfUp(100, 2)).toBe(100); + expect(roundHalfUp(100.004, 2)).toBe(100); + }); +}); diff --git a/__tests__/lib/response.test.ts b/__tests__/lib/response.test.ts new file mode 100644 index 0000000..0283213 --- /dev/null +++ b/__tests__/lib/response.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { fail, ok } from "@/lib/response"; + +describe("lib/response", () => { + it("ok 返回 code=0、message=ok、data", async () => { + const res = ok({ quote_id: "Q001" }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ + code: 0, + message: "ok", + data: { quote_id: "Q001" }, + }); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("fail 返回 error code、message、data=null 及 HTTP 状态码", async () => { + const res = fail("VALIDATION_FAILED", "请填写重量", 400); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body).toEqual({ + code: "VALIDATION_FAILED", + message: "请填写重量", + data: null, + }); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + }); +}); diff --git a/__tests__/lib/rpa/env.test.ts b/__tests__/lib/rpa/env.test.ts new file mode 100644 index 0000000..6f903e9 --- /dev/null +++ b/__tests__/lib/rpa/env.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { + assertRpaWorkerEnv, + isForbiddenQuoteUrl, + parseMothershipQuoteUrls, + shouldKeepBrowserOpen, + shouldKeepBrowserWarm, + isParkedSessionEnabled, + getRpaPlaceFixMode, + validateMothershipQuoteUrls, + isWidgetQuoteFallbackEnabled, +} from "@/lib/rpa/env"; + +describe("lib/rpa/env", () => { + it("parseMothershipQuoteUrls 解析逗号分隔列表", () => { + expect( + parseMothershipQuoteUrls( + "https://example.com/a , https://example.com/b", + ), + ).toEqual(["https://example.com/a", "https://example.com/b"]); + }); + + it("isForbiddenQuoteUrl 拦截 dashboard 与 /quote", () => { + expect(isForbiddenQuoteUrl("https://dashboard.mothership.com/")).toBe(true); + expect(isForbiddenQuoteUrl("https://www.mothership.com/quote")).toBe(true); + expect(isForbiddenQuoteUrl("https://ship.mothership.com/rate")).toBe(false); + }); + + it("validateMothershipQuoteUrls 空列表报错", () => { + expect(validateMothershipQuoteUrls([])).toContain( + "MOTHERSHIP_QUOTE_URLS 未配置或为空", + ); + }); + + it("assertRpaWorkerEnv mock 模式跳过校验", () => { + vi.stubEnv("RPA_MOCK_MODE", "true"); + vi.stubEnv("MOTHERSHIP_QUOTE_URLS", ""); + expect(() => assertRpaWorkerEnv()).not.toThrow(); + vi.unstubAllEnvs(); + }); + + it("assertRpaWorkerEnv 缺 URL 时 fail-fast", () => { + vi.stubEnv("RPA_MOCK_MODE", "false"); + vi.stubEnv("MOTHERSHIP_QUOTE_URLS", ""); + expect(() => assertRpaWorkerEnv()).toThrow(/MOTHERSHIP_QUOTE_URLS/); + vi.unstubAllEnvs(); + }); + + it("assertRpaWorkerEnv 禁止 dashboard URL", () => { + vi.stubEnv("RPA_MOCK_MODE", "false"); + vi.stubEnv( + "MOTHERSHIP_QUOTE_URLS", + "https://dashboard.mothership.com/", + ); + expect(() => assertRpaWorkerEnv()).toThrow(/禁止的报价入口 URL/); + vi.unstubAllEnvs(); + }); + + it("shouldKeepBrowserOpen 唯一 config 来源", () => { + vi.stubEnv("RPA_KEEP_BROWSER_OPEN", "true"); + expect(shouldKeepBrowserOpen()).toBe(true); + vi.stubEnv("RPA_KEEP_BROWSER_OPEN", "false"); + expect(shouldKeepBrowserOpen()).toBe(false); + vi.unstubAllEnvs(); + }); + + it("shouldKeepBrowserWarm headless 默认保活", () => { + vi.stubEnv("RPA_KEEP_BROWSER_WARM", ""); + vi.stubEnv("RPA_HEADLESS", "true"); + expect(shouldKeepBrowserWarm()).toBe(true); + vi.stubEnv("RPA_KEEP_BROWSER_WARM", "false"); + expect(shouldKeepBrowserWarm()).toBe(false); + vi.unstubAllEnvs(); + }); + + it("isParkedSessionEnabled 默认关,显式 true 才开", () => { + vi.stubEnv("RPA_PARKED_SESSION", ""); + expect(isParkedSessionEnabled()).toBe(false); + vi.stubEnv("RPA_PARKED_SESSION", "true"); + expect(isParkedSessionEnabled()).toBe(true); + vi.unstubAllEnvs(); + }); + + it("getRpaPlaceFixMode 默认 event,支持 state/legacy", () => { + vi.stubEnv("RPA_PLACE_FIX_MODE", ""); + expect(getRpaPlaceFixMode()).toBe("event"); + vi.stubEnv("RPA_PLACE_FIX_MODE", "state"); + expect(getRpaPlaceFixMode()).toBe("state"); + vi.stubEnv("RPA_PLACE_FIX_MODE", "legacy"); + expect(getRpaPlaceFixMode()).toBe("legacy"); + vi.unstubAllEnvs(); + }); + + it("isWidgetQuoteFallbackEnabled 默认开,RPA_DISABLE_WIDGET_QUOTE_FALLBACK=true 关闭", () => { + vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", ""); + expect(isWidgetQuoteFallbackEnabled()).toBe(true); + vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "true"); + expect(isWidgetQuoteFallbackEnabled()).toBe(false); + vi.unstubAllEnvs(); + }); +}); diff --git a/__tests__/lib/rpa/human-pacing.test.ts b/__tests__/lib/rpa/human-pacing.test.ts new file mode 100644 index 0000000..3acb063 --- /dev/null +++ b/__tests__/lib/rpa/human-pacing.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; +import { + humanPacingDelayMs, + RPA_HUMAN_PACING_MAX_MS, + RPA_HUMAN_PACING_MIN_MS, +} from "@/lib/rpa/human-pacing"; + +describe("human-pacing", () => { + it("默认延迟在 100~300ms", () => { + vi.stubEnv("RPA_HUMAN_PACING", ""); + for (let i = 0; i < 20; i += 1) { + const ms = humanPacingDelayMs(); + expect(ms).toBeGreaterThanOrEqual(RPA_HUMAN_PACING_MIN_MS); + expect(ms).toBeLessThanOrEqual(RPA_HUMAN_PACING_MAX_MS); + } + vi.unstubAllEnvs(); + }); + + it("RPA_HUMAN_PACING=false 时返回 0", () => { + vi.stubEnv("RPA_HUMAN_PACING", "false"); + expect(humanPacingDelayMs()).toBe(0); + vi.unstubAllEnvs(); + }); +}); diff --git a/__tests__/lib/rpa/locator.test.ts b/__tests__/lib/rpa/locator.test.ts new file mode 100644 index 0000000..4372e4b --- /dev/null +++ b/__tests__/lib/rpa/locator.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { pageLocator } from "@/lib/rpa/locator"; + +describe("pageLocator", () => { + it("解析 role= 规格", () => { + const calls: unknown[] = []; + const page = { + getByRole: (role: string, opts?: { name?: string }) => { + calls.push([role, opts]); + return { _type: "locator" }; + }, + locator: () => ({ _type: "css" }), + getByText: () => ({ _type: "text" }), + getByPlaceholder: () => ({ _type: "ph" }), + } as unknown as Parameters[0]; + + pageLocator(page, 'role=button[name="获取货运报价"]'); + expect(calls[0]).toEqual(["button", { name: "获取货运报价" }]); + }); +}); diff --git a/__tests__/lib/rpa/quote-session-store.test.ts b/__tests__/lib/rpa/quote-session-store.test.ts new file mode 100644 index 0000000..12d9b11 --- /dev/null +++ b/__tests__/lib/rpa/quote-session-store.test.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const connect = vi.fn().mockResolvedValue(undefined); +const quit = vi.fn().mockResolvedValue(undefined); +const setex = vi.fn().mockResolvedValue("OK"); +const get = vi.fn().mockResolvedValue(null); + +vi.mock("ioredis", () => ({ + default: vi.fn().mockImplementation(() => ({ + connect, + quit, + setex, + get, + })), +})); + +import { + resolveQuoteSessionStoragePath, + saveQuoteSessionFromContext, +} from "@/lib/rpa/quote-session-store"; + +describe("quote-session-store", () => { + const prevCwd = process.cwd(); + const prevRedisUrl = process.env.REDIS_URL; + let tmpDir = ""; + + afterEach(() => { + process.chdir(prevCwd); + if (prevRedisUrl === undefined) { + delete process.env.REDIS_URL; + } else { + process.env.REDIS_URL = prevRedisUrl; + } + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = ""; + } + vi.clearAllMocks(); + }); + + it("saveQuoteSessionFromContext 写入本地文件并返回 sessionId", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quote-session-")); + process.chdir(tmpDir); + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + + const storageState = vi.fn().mockImplementation(async ({ path: outPath }: { path: string }) => { + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, "{}", "utf8"); + }); + const sessionId = await saveQuoteSessionFromContext( + { storageState } as never, + { + pickup: { + street: "123 Main", + city: "LA", + state: "CA", + zip: "90001", + }, + delivery: { + street: "456 Oak", + city: "Dallas", + state: "TX", + zip: "75201", + }, + }, + ); + + expect(sessionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect( + fs.existsSync(path.join(tmpDir, ".rpa", "sessions", `${sessionId}.json`)), + ).toBe(true); + expect(setex).toHaveBeenCalled(); + }); + + it("resolveQuoteSessionStoragePath Redis 失败时回退本地文件", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quote-session-")); + process.chdir(tmpDir); + process.env.REDIS_URL = "redis://127.0.0.1:6379"; + connect.mockRejectedValueOnce(new Error("redis down")); + + const sessionId = "11111111-1111-4111-8111-111111111111"; + const sessionsDir = path.join(tmpDir, ".rpa", "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + const file = path.join(sessionsDir, `${sessionId}.json`); + fs.writeFileSync(file, "{}", "utf8"); + + const resolved = await resolveQuoteSessionStoragePath(sessionId); + expect(resolved).toBe(file); + }); +}); diff --git a/__tests__/lib/rpa/selector-specs.test.ts b/__tests__/lib/rpa/selector-specs.test.ts new file mode 100644 index 0000000..f157a76 --- /dev/null +++ b/__tests__/lib/rpa/selector-specs.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + getSelectorSpecs, + parseSelectorSpecs, +} from "@/lib/rpa/selector-specs"; + +describe("selector-specs", () => { + it("parseSelectorSpecs 支持逗号分隔双语", () => { + expect(parseSelectorSpecs("text=Pick up from,text=从何处取货")).toEqual([ + "text=Pick up from", + "text=从何处取货", + ]); + }); + + it("getSelectorSpecs 合并 env 与内置回退", () => { + process.env.RPA_SELECTOR_PICKUP_SECTION = "text=Custom pickup"; + const specs = getSelectorSpecs("RPA_SELECTOR_PICKUP_SECTION"); + expect(specs[0]).toBe("text=Custom pickup"); + expect(specs.some((s) => s.includes("Pick up from"))).toBe(true); + delete process.env.RPA_SELECTOR_PICKUP_SECTION; + }); + + it("英文 street selector 在内置回退中", () => { + const specs = getSelectorSpecs("RPA_SELECTOR_PICKUP_STREET"); + expect(specs.some((s) => s.includes("Search address"))).toBe(true); + }); +}); diff --git a/__tests__/lib/rpa/selectors.test.ts b/__tests__/lib/rpa/selectors.test.ts new file mode 100644 index 0000000..e8730af --- /dev/null +++ b/__tests__/lib/rpa/selectors.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertRpaSelectorsEnv, getSelector } from "@/lib/rpa/selectors"; + +const REQUIRED = { + RPA_SELECTOR_PICKUP_STREET: "role=textbox", + RPA_SELECTOR_DELIVERY_STREET: "role=textbox", + RPA_SELECTOR_ADDRESS_SUGGESTION: ".pac-item", + RPA_SELECTOR_SUBMIT: "role=button", + RPA_SELECTOR_QUOTE_WIDGET: "#react-quoter-landing-plugin", +} as const; + +describe("assertRpaSelectorsEnv", () => { + it("FASTEST 留空时仍可通过启动校验", () => { + vi.stubEnv("RPA_MOCK_MODE", "false"); + for (const [key, value] of Object.entries(REQUIRED)) { + vi.stubEnv(key, value); + } + vi.stubEnv("RPA_SELECTOR_FASTEST_OPTION", ""); + expect(() => assertRpaSelectorsEnv()).not.toThrow(); + vi.unstubAllEnvs(); + }); + + it("env 为空时使用内置双语回退", () => { + vi.stubEnv("RPA_MOCK_MODE", "false"); + for (const key of Object.keys(REQUIRED)) { + vi.stubEnv(key, ""); + } + vi.stubEnv("RPA_SELECTOR_FASTEST_OPTION", ""); + expect(() => assertRpaSelectorsEnv()).not.toThrow(); + vi.unstubAllEnvs(); + }); + + it("RPA_SELECTOR_QUOTE_WIDGET 显式空串时 getSelector 走内置回退,禁止空 selector", () => { + vi.stubEnv("RPA_MOCK_MODE", "false"); + for (const [key, value] of Object.entries(REQUIRED)) { + vi.stubEnv(key, value); + } + vi.stubEnv("RPA_SELECTOR_QUOTE_WIDGET", ""); + expect(getSelector("RPA_SELECTOR_QUOTE_WIDGET")).toBe( + "#react-quoter-landing-plugin", + ); + vi.unstubAllEnvs(); + }); +}); diff --git a/__tests__/lib/rpa/storage-state.test.ts b/__tests__/lib/rpa/storage-state.test.ts new file mode 100644 index 0000000..ec4057d --- /dev/null +++ b/__tests__/lib/rpa/storage-state.test.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("storage-state", () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + vi.resetModules(); + }); + + it("invalidateStorageState 删除 state 文件", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-state-")); + const stateFile = path.join(tmpDir, "state.json"); + fs.writeFileSync(stateFile, "{}"); + process.env.RPA_STORAGE_STATE_PATH = stateFile; + + const { invalidateStorageState, storageStateExists } = await import( + "@/lib/rpa/storage-state" + ); + expect(storageStateExists()).toBe(true); + invalidateStorageState(); + expect(storageStateExists()).toBe(false); + }); + + it("getStorageStateForContext 存在时返回 storageState", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-state-")); + const stateFile = path.join(tmpDir, "state.json"); + fs.writeFileSync(stateFile, '{"cookies":[]}'); + process.env.RPA_STORAGE_STATE_PATH = stateFile; + + const { getStorageStateForContext } = await import( + "@/lib/rpa/storage-state" + ); + expect(getStorageStateForContext()).toEqual({ storageState: stateFile }); + }); +}); diff --git a/__tests__/lib/rpa/worker-health.test.ts b/__tests__/lib/rpa/worker-health.test.ts new file mode 100644 index 0000000..3c512c6 --- /dev/null +++ b/__tests__/lib/rpa/worker-health.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/workers/rpa/worker-state", () => ({ + listWorkerHeartbeats: vi.fn(), +})); + +import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; +import { listWorkerHeartbeats } from "@/workers/rpa/worker-state"; + +describe("isAnyRpaWorkerHealthy", () => { + it("存在近期心跳时返回 true", async () => { + vi.mocked(listWorkerHeartbeats).mockResolvedValue([ + { workerId: "w1", lastSeenMs: Date.now() - 5_000 }, + ]); + await expect(isAnyRpaWorkerHealthy()).resolves.toBe(true); + }); + + it("无心跳或 Redis 异常时返回 false", async () => { + vi.mocked(listWorkerHeartbeats).mockResolvedValue([]); + await expect(isAnyRpaWorkerHealthy()).resolves.toBe(false); + + vi.mocked(listWorkerHeartbeats).mockRejectedValue(new Error("redis down")); + await expect(isAnyRpaWorkerHealthy()).resolves.toBe(false); + }); +}); diff --git a/__tests__/modules/address/candidate-catalog.test.ts b/__tests__/modules/address/candidate-catalog.test.ts new file mode 100644 index 0000000..0006931 --- /dev/null +++ b/__tests__/modules/address/candidate-catalog.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { listMockMothershipCandidates } from "@/modules/address/candidate-catalog"; + +describe("listMockMothershipCandidates", () => { + it("Dallas 75201 返回 4 个 MotherShip 精确坐标", () => { + const list = listMockMothershipCandidates({ + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }); + expect(list).toHaveLength(4); + expect(list.map((c) => c.display_label)).toContain( + "5678 Distribution Dr, Dallas, TX 75201", + ); + }); + + it("唯一候选时长度为 1", () => { + const list = listMockMothershipCandidates({ + street: "99 Unknown Rd", + city: "Austin", + state: "TX", + zip: "78701", + }); + expect(list).toHaveLength(1); + }); +}); diff --git a/__tests__/modules/address/candidates-queue.test.ts b/__tests__/modules/address/candidates-queue.test.ts new file mode 100644 index 0000000..75c9773 --- /dev/null +++ b/__tests__/modules/address/candidates-queue.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/rpa/worker-health", () => ({ + isAnyRpaWorkerHealthy: vi.fn(), +})); + +vi.mock("@/lib/rpa/spawn-address-candidates", () => ({ + spawnFetchMothershipAddressCandidates: vi.fn(), +})); + +vi.mock("@/workers/rpa/queue", () => ({ + addAddressCandidatesJob: vi.fn(), +})); + +const redisGet = vi.fn(); + +vi.mock("@/lib/redis", () => ({ + ensureRedisReady: vi.fn().mockResolvedValue(undefined), + getRedis: vi.fn(() => ({ + setex: vi.fn().mockResolvedValue("OK"), + get: redisGet, + })), + resetRedisClient: vi.fn(), +})); + +import { spawnFetchMothershipAddressCandidates } from "@/lib/rpa/spawn-address-candidates"; +import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; +import { requestMothershipCandidatesViaWorker } from "@/modules/address/candidates-queue"; +import { addAddressCandidatesJob } from "@/workers/rpa/queue"; + +const input = { + pickup: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + delivery: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, +}; + +const mockResult = { + pickup_candidates: [{ display_label: "A", option_id: "1" }], + delivery_candidates: [{ display_label: "B", option_id: "2" }], + requires_selection: false, +}; + +describe("requestMothershipCandidatesViaWorker", () => { + afterEach(() => { + vi.clearAllMocks(); + redisGet.mockReset(); + delete process.env.RPA_QUEUE_ENABLED; + }); + + it("Worker 无心跳时默认走 spawn", async () => { + delete process.env.RPA_PARKED_SESSION; + vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(false); + vi.mocked(spawnFetchMothershipAddressCandidates).mockResolvedValue( + mockResult, + ); + + const result = await requestMothershipCandidatesViaWorker(input); + + expect(result).toEqual(mockResult); + expect(spawnFetchMothershipAddressCandidates).toHaveBeenCalledWith(input); + }); + + it("Worker 无心跳且 RPA_PARKED_SESSION=true 时抛错", async () => { + process.env.RPA_QUEUE_ENABLED = "true"; + process.env.RPA_PARKED_SESSION = "true"; + vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(false); + + await expect(requestMothershipCandidatesViaWorker(input)).rejects.toMatchObject( + { code: "PAGE_LOAD_TIMEOUT" }, + ); + expect(spawnFetchMothershipAddressCandidates).not.toHaveBeenCalled(); + }); + + it("Worker 有心跳时只走队列等待结果,不 spawn", async () => { + process.env.RPA_QUEUE_ENABLED = "true"; + vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(true); + vi.mocked(addAddressCandidatesJob).mockResolvedValue(undefined); + redisGet + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + JSON.stringify({ ok: true, data: mockResult }), + ); + + const result = await requestMothershipCandidatesViaWorker(input); + + expect(result).toEqual(mockResult); + expect(addAddressCandidatesJob).toHaveBeenCalled(); + expect(spawnFetchMothershipAddressCandidates).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/modules/address/mothership-candidates.test.ts b/__tests__/modules/address/mothership-candidates.test.ts new file mode 100644 index 0000000..e9ca3c6 --- /dev/null +++ b/__tests__/modules/address/mothership-candidates.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/axel/candidates", () => ({ + resolveAxelMothershipCandidates: vi.fn(), +})); + +import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates"; +import { resolveAxelMothershipCandidates } from "@/lib/axel/candidates"; + +describe("resolveMothershipCandidates", () => { + const input = { + pickup: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + delivery: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + }; + + it("非 mock 模式统一走 axel search", async () => { + process.env.RPA_MOCK_MODE = "false"; + vi.mocked(resolveAxelMothershipCandidates).mockResolvedValue({ + pickup_candidates: [ + { + option_id: "ChIJpickup", + display_label: "5678 Distribution Dr, Dallas, TX 75201", + formatted_address: "5678 Distribution Dr, Dallas, TX 75201", + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ], + delivery_candidates: [ + { + option_id: "ChIJdelivery", + display_label: "1234 Warehouse Blvd, Los Angeles, CA 90001", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001", + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + ], + }); + + const result = await resolveMothershipCandidates(input); + expect(resolveAxelMothershipCandidates).toHaveBeenCalledWith(input); + expect(result.pickup_candidates[0].option_id).toBe("ChIJpickup"); + }); +}); diff --git a/__tests__/modules/alert/deviation-detector.test.ts b/__tests__/modules/alert/deviation-detector.test.ts new file mode 100644 index 0000000..1b1dadf --- /dev/null +++ b/__tests__/modules/alert/deviation-detector.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const prismaMock = vi.hoisted(() => ({ + quoteCacheMeta: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + alertLog: { + create: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ prisma: prismaMock })); + +import { detectDeviation } from "@/modules/alert/deviation-detector"; +import type { RawQuoteTier } from "@/modules/pricing/engine"; + +const fourTiers = (): RawQuoteTier[] => [ + { + service_level: "standard", + rate_option: "lowest", + carrier: "A", + transit_days: "3", + transit_description: "3d", + raw_freight: 320, + surcharges: 0, + raw_total: 320, + }, + { + service_level: "standard", + rate_option: "fastest", + carrier: "A", + transit_days: "2", + transit_description: "2d", + raw_freight: 380, + surcharges: 0, + raw_total: 380, + }, + { + service_level: "guaranteed", + rate_option: "lowest", + carrier: "B", + transit_days: "3", + transit_description: "3d", + raw_freight: 420, + surcharges: 0, + raw_total: 420, + }, + { + service_level: "guaranteed", + rate_option: "fastest", + carrier: "B", + transit_days: "2", + transit_description: "2d", + raw_freight: 480, + surcharges: 0, + raw_total: 480, + }, +]; + +describe("detectDeviation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("TC-212 首次询价不触发偏差预警", async () => { + prismaMock.quoteCacheMeta.findUnique.mockResolvedValue(null); + prismaMock.quoteCacheMeta.create.mockResolvedValue({}); + + await detectDeviation("hash1", fourTiers(), "QTE001"); + + expect(prismaMock.alertLog.create).not.toHaveBeenCalled(); + expect(prismaMock.quoteCacheMeta.create).toHaveBeenCalled(); + }); + + it("TC-210 偏差≥5% 写 PRICE_DEVIATION", async () => { + prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({ + cargoHash: "hash1", + rawTotalStandard: 352, + }); + prismaMock.quoteCacheMeta.update.mockResolvedValue({}); + prismaMock.alertLog.create.mockResolvedValue({}); + + const quotes = fourTiers(); + quotes[0].raw_total = 370; + quotes[0].raw_freight = 370; + + await detectDeviation("hash1", quotes, "QTE002"); + + expect(prismaMock.alertLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ alertType: "PRICE_DEVIATION" }), + }), + ); + }); + + it("TC-211 偏差4.9% 不触发", async () => { + prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({ + cargoHash: "hash1", + rawTotalStandard: 352, + }); + prismaMock.quoteCacheMeta.update.mockResolvedValue({}); + + const quotes = fourTiers(); + quotes[0].raw_total = 369.25; + quotes[0].raw_freight = 369.25; + + await detectDeviation("hash1", quotes, "QTE003"); + + expect(prismaMock.alertLog.create).not.toHaveBeenCalled(); + expect(prismaMock.quoteCacheMeta.update).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/modules/alert/service.test.ts b/__tests__/modules/alert/service.test.ts new file mode 100644 index 0000000..7e81a4c --- /dev/null +++ b/__tests__/modules/alert/service.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const prismaMock = vi.hoisted(() => ({ + alertLog: { + create: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ prisma: prismaMock })); + +import { writeAlert } from "@/modules/alert/service"; + +describe("writeAlert", () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) }); + }); + + it("写入 alert_log 成功", async () => { + await writeAlert("RPA_FAILED", { + quoteId: "QTE_001", + cargoHash: "abc123", + detail: { reason: "timeout" }, + }); + + expect(prismaMock.alertLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + alertType: "RPA_FAILED", + quoteId: "QTE_001", + cargoHash: "abc123", + status: "open", + }), + }); + }); + + it("DB 写失败不抛异常", async () => { + prismaMock.alertLog.create.mockRejectedValue(new Error("db down")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + writeAlert("SECURITY", { detail: { reason: "test" } }), + ).resolves.toBeUndefined(); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); diff --git a/__tests__/modules/audit/service.test.ts b/__tests__/modules/audit/service.test.ts new file mode 100644 index 0000000..2195b27 --- /dev/null +++ b/__tests__/modules/audit/service.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const prismaMock = vi.hoisted(() => ({ + auditLog: { create: vi.fn() }, + alertLog: { create: vi.fn() }, +})); + +vi.mock("@/lib/prisma", () => ({ prisma: prismaMock })); + +import { recordSecurityEvent, writeAudit } from "@/modules/audit/service"; + +describe("writeAudit", () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.auditLog.create.mockResolvedValue({ id: BigInt(1) }); + prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) }); + }); + + it("写入 audit_log 成功", async () => { + await writeAudit("FORBIDDEN_ACCESS", "CUST_001", "QTE_B", { + owner: "CUST_B", + }); + + expect(prismaMock.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "FORBIDDEN_ACCESS", + actorId: "CUST_001", + resource: "QTE_B", + }), + }); + }); + + it("DB 写失败不抛异常", async () => { + prismaMock.auditLog.create.mockRejectedValue(new Error("db down")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + writeAudit("TEST", "actor", "resource"), + ).resolves.toBeUndefined(); + + consoleSpy.mockRestore(); + }); +}); + +describe("recordSecurityEvent", () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.auditLog.create.mockResolvedValue({ id: BigInt(1) }); + prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) }); + }); + + it("同时写 audit + SECURITY 预警", async () => { + await recordSecurityEvent( + "SECURITY_VALIDATION_BLOCKED", + "CUST_001", + "POST /api/quotes", + { message: "injection" }, + ); + + expect(prismaMock.auditLog.create).toHaveBeenCalled(); + expect(prismaMock.alertLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ alertType: "SECURITY" }), + }); + }); +}); diff --git a/__tests__/modules/auth/jwt.test.ts b/__tests__/modules/auth/jwt.test.ts new file mode 100644 index 0000000..ca3536b --- /dev/null +++ b/__tests__/modules/auth/jwt.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + decodeToken, + signToken, + verifyToken, +} from "@/modules/auth/jwt"; + +describe("modules/auth/jwt", () => { + beforeEach(() => { + process.env.JWT_SECRET = "test-jwt-secret-for-unit-tests"; + }); + + it("签发后校验通过", async () => { + const token = await signToken({ sub: "ADMIN_001", role: "admin" }); + const payload = await verifyToken(token); + expect(payload).toEqual({ sub: "ADMIN_001", role: "admin" }); + }); + + it("篡改 token 后校验失败", async () => { + const token = await signToken({ sub: "ADMIN_001", role: "admin" }); + const tampered = `${token.slice(0, -4)}xxxx`; + await expect(verifyToken(tampered)).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("过期 token 校验失败", async () => { + const { SignJWT } = await import("jose"); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const expired = await new SignJWT({ role: "admin" }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject("ADMIN_001") + .setIssuedAt(Math.floor(Date.now() / 1000) - 7200) + .setExpirationTime(Math.floor(Date.now() / 1000) - 3600) + .sign(secret); + + await expect(verifyToken(expired)).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("decodeToken 可解析有效载荷", async () => { + const token = await signToken({ sub: "ADMIN_001", role: "admin" }); + expect(decodeToken(token)).toEqual({ sub: "ADMIN_001", role: "admin" }); + }); +}); diff --git a/__tests__/modules/auth/rbac.test.ts b/__tests__/modules/auth/rbac.test.ts new file mode 100644 index 0000000..9665747 --- /dev/null +++ b/__tests__/modules/auth/rbac.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { requirePermission } from "@/modules/auth/rbac"; + +describe("modules/auth/rbac", () => { + const adminAuth = { + type: "admin" as const, + userId: "ADMIN_001", + role: "admin" as const, + }; + + const hostAuth = { + type: "service" as const, + customerId: "CUST_001", + permissions: ["pricing:markup:write" as const], + }; + + it("admin + alert:read 通过", () => { + expect(() => requirePermission(adminAuth, "alert:read")).not.toThrow(); + }); + + it("admin + rpa:operate 通过", () => { + expect(() => requirePermission(adminAuth, "rpa:operate")).not.toThrow(); + }); + + it("宿主 token + alert:read 拒绝", () => { + expect(() => requirePermission(hostAuth, "alert:read")).toThrow( + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + + it("宿主 token + pricing:markup:write 通过", () => { + expect(() => + requirePermission(hostAuth, "pricing:markup:write"), + ).not.toThrow(); + }); +}); diff --git a/__tests__/modules/auth/service-token.test.ts b/__tests__/modules/auth/service-token.test.ts new file mode 100644 index 0000000..314fbea --- /dev/null +++ b/__tests__/modules/auth/service-token.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + resetServiceTokenCache, + verifyServiceToken, +} from "@/modules/auth/service-token"; + +const DEMO_TOKENS = JSON.stringify({ + "demo-host-token": { + customerId: "CUST_001", + permissions: ["pricing:markup:write"], + }, +}); + +describe("modules/auth/service-token", () => { + beforeEach(() => { + process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS; + resetServiceTokenCache(); + }); + + afterEach(() => { + delete process.env.HOST_SERVICE_TOKENS; + resetServiceTokenCache(); + }); + + it("正确 token + 匹配 customer_id 通过", () => { + const result = verifyServiceToken("demo-host-token", "CUST_001"); + expect(result.customerId).toBe("CUST_001"); + expect(result.permissions).toContain("pricing:markup:write"); + }); + + it("customer_id 不匹配返回 403", () => { + expect(() => verifyServiceToken("demo-host-token", "CUST_002")).toThrow( + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + + it("无效 token 返回 401", () => { + expect(() => verifyServiceToken("invalid-token", "CUST_001")).toThrow( + expect.objectContaining({ code: "UNAUTHORIZED" }), + ); + }); +}); diff --git a/__tests__/modules/cache/cache.test.ts b/__tests__/modules/cache/cache.test.ts new file mode 100644 index 0000000..24048ce --- /dev/null +++ b/__tests__/modules/cache/cache.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + L1_TTL_SECONDS, + L2_TTL_BASE_SECONDS, + L2_TTL_JITTER_MAX_SECONDS, + L3_TTL_SECONDS, + LOCK_TTL_SECONDS, +} from "@/lib/constants/cache"; + +type RedisMock = { + store: Map; + get: ReturnType; + set: ReturnType; + del: ReturnType; + exists: ReturnType; + incr: ReturnType; + expire: ReturnType; +}; + +function createRedisMock(): RedisMock { + const store = new Map(); + + const isExpired = (key: string): boolean => { + const entry = store.get(key); + if (!entry?.expireAt) { + return false; + } + return Date.now() > entry.expireAt; + }; + + return { + store, + get: vi.fn(async (key: string) => { + if (isExpired(key)) { + store.delete(key); + return null; + } + return store.get(key)?.value ?? null; + }), + set: vi.fn( + async ( + key: string, + value: string, + ...args: (string | number)[] + ): Promise => { + const nx = args.includes("NX"); + if (nx && store.has(key) && !isExpired(key)) { + return null; + } + + let ttlSeconds: number | undefined; + const exIndex = args.indexOf("EX"); + if (exIndex >= 0) { + ttlSeconds = Number(args[exIndex + 1]); + } + + store.set(key, { + value, + expireAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined, + }); + return "OK"; + }, + ), + del: vi.fn(async (key: string) => { + store.delete(key); + return 1; + }), + exists: vi.fn(async (key: string) => { + if (isExpired(key)) { + store.delete(key); + return 0; + } + return store.has(key) ? 1 : 0; + }), + incr: vi.fn(async (key: string) => { + const current = store.get(key); + const next = current ? Number(current.value) + 1 : 1; + store.set(key, { value: String(next) }); + return next; + }), + expire: vi.fn(async (key: string, seconds: number) => { + const entry = store.get(key); + if (!entry) { + return 0; + } + entry.expireAt = Date.now() + seconds * 1000; + return 1; + }), + }; +} + +let redisMock: RedisMock; + +vi.mock("@/lib/redis", () => ({ + getRedis: () => redisMock, +})); + +describe("modules/cache/redis-cache", () => { + beforeEach(async () => { + redisMock = createRedisMock(); + vi.resetModules(); + }); + + async function loadCache() { + return import("@/modules/cache/redis-cache"); + } + + it("L1 setL1 SETNX 不覆盖 + TTL 86400", async () => { + const cache = await loadCache(); + const payload = { quote_id: "Q1", status: "done" }; + + expect(await cache.setL1("req-1", payload)).toBe(true); + expect(await cache.setL1("req-1", { quote_id: "Q2" })).toBe(false); + expect(await cache.getL1("req-1")).toEqual(payload); + + const [, , , ttl] = redisMock.set.mock.calls[0]; + expect(ttl).toBe(L1_TTL_SECONDS); + }); + + it("L2 TTL 在 180~210s 之间", async () => { + const cache = await loadCache(); + const ttls = new Set(); + + for (let i = 0; i < 20; i++) { + redisMock.store.clear(); + const ttl = await cache.setL2("hash-1", { quotes: [] }); + ttls.add(ttl); + } + + for (const ttl of ttls) { + expect(ttl).toBeGreaterThanOrEqual(L2_TTL_BASE_SECONDS); + expect(ttl).toBeLessThanOrEqual( + L2_TTL_BASE_SECONDS + L2_TTL_JITTER_MAX_SECONDS, + ); + } + }); + + it("L3 TTL 1800s", async () => { + const cache = await loadCache(); + await cache.setL3("hash-1", { quotes: [] }); + + const [, , , ttl] = redisMock.set.mock.calls[0]; + expect(ttl).toBe(L3_TTL_SECONDS); + }); + + it("acquireLock SETNX TTL 5s", async () => { + const cache = await loadCache(); + + expect(await cache.acquireLock("hash-1")).toBe(true); + expect(await cache.acquireLock("hash-1")).toBe(false); + + const [, , , ttl] = redisMock.set.mock.calls[0]; + expect(ttl).toBe(LOCK_TTL_SECONDS); + }); +}); + +describe("modules/cache/rate-limiter", () => { + beforeEach(async () => { + redisMock = createRedisMock(); + vi.resetModules(); + }); + + it("60 次通过,第 61 次 RATE_LIMITED", async () => { + const { checkRateLimit } = await import("@/modules/cache/rate-limiter"); + + for (let i = 0; i < 60; i++) { + expect(await checkRateLimit("CUST_001")).toEqual({ allowed: true }); + } + + expect(await checkRateLimit("CUST_001")).toEqual({ + allowed: false, + errorCode: "RATE_LIMITED", + }); + }); +}); + +describe("modules/cache/ip-rate-limiter", () => { + beforeEach(async () => { + redisMock = createRedisMock(); + vi.resetModules(); + }); + + it("超 1000 次封禁 IP", async () => { + const { checkIpRateLimit } = await import( + "@/modules/cache/ip-rate-limiter" + ); + + for (let i = 0; i < 1000; i++) { + expect(await checkIpRateLimit("1.2.3.4")).toEqual({ allowed: true }); + } + + const blocked = await checkIpRateLimit("1.2.3.4"); + expect(blocked).toEqual({ + allowed: false, + errorCode: "RATE_LIMITED", + banned: true, + }); + + expect(await checkIpRateLimit("1.2.3.4")).toEqual({ + allowed: false, + errorCode: "RATE_LIMITED", + banned: true, + }); + }); +}); + +describe("modules/cache/circuit-breaker", () => { + beforeEach(async () => { + redisMock = createRedisMock(); + vi.resetModules(); + }); + + it("连续 3 次失败熔断", async () => { + const breaker = await import("@/modules/cache/circuit-breaker"); + + expect(await breaker.isCircuitOpen()).toBe(false); + await breaker.recordRpaFailure(); + await breaker.recordRpaFailure(); + expect(await breaker.isCircuitOpen()).toBe(false); + await breaker.recordRpaFailure(); + expect(await breaker.isCircuitOpen()).toBe(true); + }); + + it("closeCircuit 清除熔断状态", async () => { + const breaker = await import("@/modules/cache/circuit-breaker"); + + await breaker.openCircuit(); + expect(await breaker.isCircuitOpen()).toBe(true); + await breaker.closeCircuit(); + expect(await breaker.isCircuitOpen()).toBe(false); + }); +}); diff --git a/__tests__/modules/metrics/collector.test.ts b/__tests__/modules/metrics/collector.test.ts new file mode 100644 index 0000000..c9f5582 --- /dev/null +++ b/__tests__/modules/metrics/collector.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { METRIC_COUNTER } from "@/lib/constants/metrics"; +import { + computeP95, + computeRate, + computeRates, + type MetricsCounters, +} from "@/modules/metrics/collector"; + +function emptyCounters(): MetricsCounters { + return { + [METRIC_COUNTER.POST_TOTAL]: 0, + [METRIC_COUNTER.POST_DONE]: 0, + [METRIC_COUNTER.CACHE_HIT_L1]: 0, + [METRIC_COUNTER.CACHE_HIT_L2]: 0, + [METRIC_COUNTER.RPA_TRIGGERED]: 0, + [METRIC_COUNTER.RPA_DONE]: 0, + [METRIC_COUNTER.DONE_TOTAL]: 0, + [METRIC_COUNTER.DONE_REALTIME]: 0, + }; +} + +describe("computeRates (GD-4)", () => { + it("100 次 mock:80 done / 100 post → api 80%", () => { + const counters: MetricsCounters = { + ...emptyCounters(), + [METRIC_COUNTER.POST_TOTAL]: 100, + [METRIC_COUNTER.POST_DONE]: 80, + }; + expect(computeRates(counters).api_success_rate).toBe(80); + }); + + it("RPA 85/100 → rpa_success_rate 85%", () => { + const counters: MetricsCounters = { + ...emptyCounters(), + [METRIC_COUNTER.RPA_TRIGGERED]: 100, + [METRIC_COUNTER.RPA_DONE]: 85, + }; + expect(computeRates(counters).rpa_success_rate).toBe(85); + }); + + it("realtime 90/100 done → realtime_rate 90%", () => { + const counters: MetricsCounters = { + ...emptyCounters(), + [METRIC_COUNTER.DONE_TOTAL]: 100, + [METRIC_COUNTER.DONE_REALTIME]: 90, + }; + expect(computeRates(counters).realtime_rate).toBe(90); + }); + + it("cache (L1+L2) 90/100 post → cache_hit_rate 90%", () => { + const counters: MetricsCounters = { + ...emptyCounters(), + [METRIC_COUNTER.POST_TOTAL]: 100, + [METRIC_COUNTER.CACHE_HIT_L1]: 40, + [METRIC_COUNTER.CACHE_HIT_L2]: 50, + }; + expect(computeRates(counters).cache_hit_rate).toBe(90); + }); + + it("分母为 0 → 返回 0", () => { + expect(computeRate(10, 0)).toBe(0); + expect(computeRates(emptyCounters()).api_success_rate).toBe(0); + }); +}); + +describe("computeP95", () => { + it("100 样本 P95 取第 95 百分位", () => { + const samples = Array.from({ length: 100 }, (_, index) => index + 1); + expect(computeP95(samples)).toBe(95); + }); + + it("空样本 → 0", () => { + expect(computeP95([])).toBe(0); + }); +}); diff --git a/__tests__/modules/pricing/engine.test.ts b/__tests__/modules/pricing/engine.test.ts new file mode 100644 index 0000000..ae921f5 --- /dev/null +++ b/__tests__/modules/pricing/engine.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + applyMarkup, + applyMarkupToTier, + getMarkupPercent, +} from "@/modules/pricing/engine"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + markupConfig: { + findFirst: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/prisma"; + +const mockedFindFirst = vi.mocked(prisma.markupConfig.findFirst); + +describe("applyMarkup", () => { + it("TC-103:raw_freight=320,surcharges=15,markup=10% → 32.00/367.00", () => { + const rawTotal = 320 + 15; + const result = applyMarkup(320, rawTotal, 10); + expect(result.markupAmount).toBe(32.0); + expect(result.finalTotal).toBe(367.0); + }); + + it("raw_freight=380,markup=10% → 38.00/418.00", () => { + const result = applyMarkup(380, 380, 10); + expect(result.markupAmount).toBe(38.0); + expect(result.finalTotal).toBe(418.0); + }); + + it("surcharge=0 仅对 raw_freight 加价", () => { + const result = applyMarkup(320, 320, 10); + expect(result.markupAmount).toBe(32.0); + expect(result.finalTotal).toBe(352.0); + }); + + it("0% 加价", () => { + const result = applyMarkup(320, 335, 0); + expect(result.markupAmount).toBe(0); + expect(result.finalTotal).toBe(335); + }); + + it("30% 边界", () => { + const result = applyMarkup(100, 100, 30); + expect(result.markupAmount).toBe(30); + expect(result.finalTotal).toBe(130); + }); + + it("固定金额加价", () => { + const result = applyMarkup(320, 335, { + type: "fixed", + percent: 0, + fixedAmount: 50, + }); + expect(result.markupAmount).toBe(50); + expect(result.finalTotal).toBe(385); + }); +}); + +describe("applyMarkupToTier", () => { + it("生成 breakdown 三项", () => { + const tier = applyMarkupToTier( + { + service_level: "standard", + rate_option: "lowest", + carrier: "Mothership", + transit_days: "5-7", + transit_description: "最低价格", + raw_freight: 320, + surcharges: 15, + raw_total: 335, + }, + 10, + ); + expect(tier.breakdown).toHaveLength(3); + expect(tier.final_total).toBe(367); + }); +}); + +describe("getMarkupPercent", () => { + beforeEach(() => { + mockedFindFirst.mockReset(); + }); + + it("未配置 customer_id → 0.0", async () => { + mockedFindFirst.mockResolvedValue(null); + expect(await getMarkupPercent("CUST_UNKNOWN")).toBe(0); + }); + + it("已配置 10.0% → 10.0", async () => { + mockedFindFirst.mockResolvedValue({ + markupType: "percent", + markupPercent: 10.0, + markupFixedAmount: null, + } as never); + expect(await getMarkupPercent("CUST_002")).toBe(10); + }); +}); diff --git a/__tests__/modules/pricing/markup-validation.test.ts b/__tests__/modules/pricing/markup-validation.test.ts new file mode 100644 index 0000000..d62f57a --- /dev/null +++ b/__tests__/modules/pricing/markup-validation.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parseMarkupInput } from "@/modules/pricing/markup-validation"; + +describe("parseMarkupInput", () => { + it("默认百分比模式", () => { + const result = parseMarkupInput({ markup_percent: 10 }); + expect(result).toEqual({ + markupType: "percent", + markupPercent: 10, + markupFixedAmount: null, + }); + }); + + it("固定金额模式", () => { + const result = parseMarkupInput({ + markup_type: "fixed", + markup_fixed_amount: 50.5, + }); + expect(result).toEqual({ + markupType: "fixed", + markupPercent: 0, + markupFixedAmount: 50.5, + }); + }); + + it("比例超过 30% 拒绝", () => { + const result = parseMarkupInput({ markup_percent: 30.1 }); + expect(result).toMatchObject({ + code: "PERCENT_TOO_HIGH", + message: "加价比例不能超过 30%", + }); + }); +}); diff --git a/__tests__/modules/quote/cargo-hash.test.ts b/__tests__/modules/quote/cargo-hash.test.ts new file mode 100644 index 0000000..3499da7 --- /dev/null +++ b/__tests__/modules/quote/cargo-hash.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + buildCargoHash, + serializeAddress, +} from "@/modules/quote/cargo-hash"; + +const baseInput = { + pickupSerialized: serializeAddress({ + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + }), + deliverySerialized: serializeAddress({ + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + }), + weightLb: 500, + dimLIn: 48, + dimWIn: 40, + dimHIn: 48, + palletCount: 2, + cargoType: "general_freight", +}; + +describe("buildCargoHash", () => { + it("相同入参 → 相同 hash", () => { + const h1 = buildCargoHash(baseInput); + const h2 = buildCargoHash(baseInput); + expect(h1).toBe(h2); + expect(h1).toHaveLength(32); + }); + + it("place_id 不同 → hash 不同", () => { + const h1 = buildCargoHash(baseInput); + const h2 = buildCargoHash({ + ...baseInput, + pickupSerialized: serializeAddress({ + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_other", + }), + }); + expect(h1).not.toBe(h2); + }); + + it("service_level 不在 hash 中(cargo_type 相同则 hash 相同)", () => { + const h1 = buildCargoHash(baseInput); + const h2 = buildCargoHash({ ...baseInput, cargoType: "general_freight" }); + expect(h1).toBe(h2); + }); +}); diff --git a/__tests__/modules/quote/confidence.test.ts b/__tests__/modules/quote/confidence.test.ts new file mode 100644 index 0000000..552fadc --- /dev/null +++ b/__tests__/modules/quote/confidence.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { getConfidenceScore } from "@/modules/quote/confidence"; + +describe("getConfidenceScore", () => { + it("source=rpa → 0.95", () => { + expect(getConfidenceScore("rpa")).toBe(0.95); + }); + + it("source=cache → 0.95", () => { + expect(getConfidenceScore("cache")).toBe(0.95); + }); + + it("source=stale → 0.70", () => { + expect(getConfidenceScore("stale")).toBe(0.7); + }); +}); diff --git a/__tests__/modules/quote/fallback-entry.test.ts b/__tests__/modules/quote/fallback-entry.test.ts new file mode 100644 index 0000000..426a1f1 --- /dev/null +++ b/__tests__/modules/quote/fallback-entry.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + shouldConsumeRpaRetry, +} from "@/modules/quote/fallback-orchestrator"; +import { RpaError } from "@/modules/rpa/errors"; + +describe("fallback entry errors (task-129)", () => { + it("QUOTE_ENTRY_UNAVAILABLE 不消耗重试配额", () => { + expect( + shouldConsumeRpaRetry( + new RpaError("QUOTE_ENTRY_UNAVAILABLE", "入口不可用", { + retryable: false, + }), + ), + ).toBe(false); + }); + + it("ADDRESS_SUGGESTION_NOT_FOUND 不消耗重试配额", () => { + expect( + shouldConsumeRpaRetry( + new RpaError("ADDRESS_SUGGESTION_NOT_FOUND", "联想失败", { + retryable: false, + }), + ), + ).toBe(false); + }); + + it("PAGE_LOAD_TIMEOUT 仍可重试", () => { + expect( + shouldConsumeRpaRetry( + new RpaError("PAGE_LOAD_TIMEOUT", "超时"), + ), + ).toBe(true); + }); +}); diff --git a/__tests__/modules/quote/fallback-orchestrator.test.ts b/__tests__/modules/quote/fallback-orchestrator.test.ts new file mode 100644 index 0000000..230812a --- /dev/null +++ b/__tests__/modules/quote/fallback-orchestrator.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleRpaFailure } from "@/modules/quote/fallback-orchestrator"; + +vi.mock("@/modules/cache/redis-cache", () => ({ + getL3: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { update: vi.fn() }, + alertLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/modules/pricing/engine", () => ({ + getMarkupPercent: vi.fn().mockResolvedValue(10), + applyMarkupToQuotes: vi.fn((quotes: unknown[]) => + quotes.map((q) => ({ ...(q as object), markup_percent: 10 })), + ), +})); + +import { getL3 } from "@/modules/cache/redis-cache"; +import { prisma } from "@/lib/prisma"; + +const mockedGetL3 = vi.mocked(getL3); +const mockedUpdate = vi.mocked(prisma.quoteRecord.update); +const mockedAlertCreate = vi.mocked(prisma.alertLog.create); + +const FOUR_TIERS = [ + { service_level: "standard", rate_option: "lowest", raw_freight: 320, surcharges: 0, raw_total: 320, carrier: "M", transit_days: "5", transit_description: "x" }, + { service_level: "standard", rate_option: "fastest", raw_freight: 365, surcharges: 0, raw_total: 365, carrier: "M", transit_days: "3", transit_description: "x" }, + { service_level: "guaranteed", rate_option: "lowest", raw_freight: 380, surcharges: 0, raw_total: 380, carrier: "M", transit_days: "4", transit_description: "x" }, + { service_level: "guaranteed", rate_option: "fastest", raw_freight: 410, surcharges: 0, raw_total: 410, carrier: "M", transit_days: "3", transit_description: "x" }, +]; + +describe("handleRpaFailure", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedUpdate.mockResolvedValue({} as never); + mockedAlertCreate.mockResolvedValue({} as never); + }); + + it("TC-205:L3 命中 → done stale + STALE_FALLBACK", async () => { + mockedGetL3.mockResolvedValue({ quotes: FOUR_TIERS }); + + const result = await handleRpaFailure( + "QTE_20260616_0001", + "abc123", + "CUST_001", + new Error("captcha"), + ); + + expect(result.status).toBe("done"); + if (result.status === "done") { + expect(result.sourceType).toBe("stale"); + expect(result.isRealtime).toBe(false); + } + expect(mockedAlertCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ alertType: "STALE_FALLBACK" }), + }), + ); + }); + + it("TC-206:无 L3 → failed QUOTE_UNAVAILABLE + RPA_FAILED", async () => { + mockedGetL3.mockResolvedValue(null); + + const result = await handleRpaFailure( + "QTE_20260616_0001", + "abc123", + "CUST_001", + new Error("timeout"), + ); + + expect(result).toEqual({ + status: "failed", + errorCode: "QUOTE_UNAVAILABLE", + errorMessage: "timeout", + }); + expect(mockedAlertCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ alertType: "RPA_FAILED" }), + }), + ); + }); +}); diff --git a/__tests__/modules/quote/idempotency.test.ts b/__tests__/modules/quote/idempotency.test.ts new file mode 100644 index 0000000..ddfd51e --- /dev/null +++ b/__tests__/modules/quote/idempotency.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + checkIdempotency, + saveIdempotency, +} from "@/modules/quote/idempotency"; + +vi.mock("@/modules/cache/redis-cache", () => ({ + getL1: vi.fn(), + setL1: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + idempotencyRecord: { + findUnique: vi.fn(), + create: vi.fn(), + }, + quoteRecord: { + findUnique: vi.fn(), + }, + }, +})); + +import { getL1, setL1 } from "@/modules/cache/redis-cache"; +import { prisma } from "@/lib/prisma"; + +const mockedGetL1 = vi.mocked(getL1); +const mockedSetL1 = vi.mocked(setL1); +const mockedFindUnique = vi.mocked(prisma.idempotencyRecord.findUnique); +const mockedCreate = vi.mocked(prisma.idempotencyRecord.create); +const mockedQuoteFind = vi.mocked(prisma.quoteRecord.findUnique); + +describe("checkIdempotency", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("L1 命中 → 返回 quote_id", async () => { + mockedGetL1.mockResolvedValue({ + quoteId: "QTE_20260616_0001", + response: { quote_id: "QTE_20260616_0001", status: "done" }, + }); + + const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000"); + expect(result.hit).toBe(true); + if (result.hit) { + expect(result.quoteId).toBe("QTE_20260616_0001"); + } + }); + + it("L1 未命中、DB 命中 → 回填 L1", async () => { + mockedGetL1.mockResolvedValue(null); + mockedFindUnique.mockResolvedValue({ + quoteId: "QTE_20260616_0002", + } as never); + mockedQuoteFind.mockResolvedValue({ + quoteId: "QTE_20260616_0002", + status: "done", + } as never); + + const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000"); + expect(result.hit).toBe(true); + expect(mockedSetL1).toHaveBeenCalled(); + }); + + it("均未命中 → hit=false", async () => { + mockedGetL1.mockResolvedValue(null); + mockedFindUnique.mockResolvedValue(null); + + const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000"); + expect(result.hit).toBe(false); + }); +}); + +describe("saveIdempotency", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedCreate.mockResolvedValue({} as never); + mockedSetL1.mockResolvedValue(true); + }); + + it("写入 DB + Redis", async () => { + await saveIdempotency( + "550e8400-e29b-41d4-a716-446655440000", + "CUST_001", + "QTE_20260616_0001", + { quote_id: "QTE_20260616_0001", status: "done" }, + ); + expect(mockedCreate).toHaveBeenCalled(); + expect(mockedSetL1).toHaveBeenCalled(); + }); + + it("P2002 冲突 → 静默返回", async () => { + mockedCreate.mockRejectedValue({ code: "P2002" }); + mockedFindUnique.mockResolvedValue({ + quoteId: "QTE_20260616_0001", + } as never); + + await expect( + saveIdempotency( + "550e8400-e29b-41d4-a716-446655440000", + "CUST_001", + "QTE_20260616_0001", + {}, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/modules/quote/orchestrator.test.ts b/__tests__/modules/quote/orchestrator.test.ts new file mode 100644 index 0000000..4faa498 --- /dev/null +++ b/__tests__/modules/quote/orchestrator.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { submitQuote } from "@/modules/quote/orchestrator"; + +vi.mock("@/modules/cache/redis-cache", () => ({ + getL2: vi.fn(), +})); + +vi.mock("@/modules/quote/idempotency", () => ({ + checkIdempotency: vi.fn(), + saveIdempotency: vi.fn(), +})); + +vi.mock("@/modules/quote/quote-id", () => ({ + generateQuoteId: vi.fn(), + handleQuoteIdConflict: vi.fn(), + isQuoteIdUniqueConflict: vi.fn(), +})); + +vi.mock("@/modules/quote/rpa-queue", () => ({ + enqueueQuoteJob: vi.fn(), +})); + +vi.mock("@/modules/pricing/engine", () => ({ + getMarkupPercent: vi.fn().mockResolvedValue(0), + applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes), +})); + +vi.mock("@/modules/metrics/collector", () => ({ + safeRecord: vi.fn(), + recordPostTotal: vi.fn().mockResolvedValue(undefined), + recordCacheHit: vi.fn().mockResolvedValue(undefined), + recordPostDone: vi.fn().mockResolvedValue(undefined), + recordRpaTriggered: vi.fn().mockResolvedValue(undefined), + markQuoteStarted: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { create: vi.fn() }, + }, +})); + +import { getL2 } from "@/modules/cache/redis-cache"; +import { checkIdempotency, saveIdempotency } from "@/modules/quote/idempotency"; +import { generateQuoteId } from "@/modules/quote/quote-id"; +import { enqueueQuoteJob } from "@/modules/quote/rpa-queue"; +import { prisma } from "@/lib/prisma"; +import { + MOTHERSHIP_DELIVERY_CONFIRMED, + MOTHERSHIP_PICKUP_CONFIRMED, +} from "@/__tests__/fixtures/mothership-address"; + +const VALID_BODY = { + request_id: "550e8400-e29b-41d4-a716-446655440000", + customer_id: "CUST_001", + pickup_address: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_PICKUP_CONFIRMED, + }, + delivery_address: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_DELIVERY_CONFIRMED, + }, + weight: { value: 500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + pallet_count: 2, + cargo_type: "general_freight", +}; + +const FOUR_TIERS = [ + { service_level: "standard", rate_option: "lowest", raw_freight: 320, surcharges: 0, raw_total: 320, carrier: "M", transit_days: "5", transit_description: "x" }, + { service_level: "standard", rate_option: "fastest", raw_freight: 365, surcharges: 0, raw_total: 365, carrier: "M", transit_days: "3", transit_description: "x" }, + { service_level: "guaranteed", rate_option: "lowest", raw_freight: 380, surcharges: 0, raw_total: 380, carrier: "M", transit_days: "4", transit_description: "x" }, + { service_level: "guaranteed", rate_option: "fastest", raw_freight: 410, surcharges: 0, raw_total: 410, carrier: "M", transit_days: "3", transit_description: "x" }, +]; + +const mockedCheckIdem = vi.mocked(checkIdempotency); +const mockedGetL2 = vi.mocked(getL2); +const mockedGenerateId = vi.mocked(generateQuoteId); +const mockedCreate = vi.mocked(prisma.quoteRecord.create); +const mockedSaveIdem = vi.mocked(saveIdempotency); +const mockedEnqueue = vi.mocked(enqueueQuoteJob); + +describe("submitQuote", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedCheckIdem.mockResolvedValue({ hit: false }); + mockedGetL2.mockResolvedValue(null); + mockedGenerateId.mockResolvedValue("QTE_20260616_0001"); + mockedCreate.mockResolvedValue({} as never); + mockedSaveIdem.mockResolvedValue(undefined); + mockedEnqueue.mockResolvedValue(undefined); + }); + + it("TC-105:L1 命中 → 返回原 quote_id", async () => { + mockedCheckIdem.mockResolvedValue({ + hit: true, + quoteId: "QTE_20260616_0001", + response: { quote_id: "QTE_20260616_0001", status: "done" }, + }); + + const result = await submitQuote(VALID_BODY); + expect(result.quote_id).toBe("QTE_20260616_0001"); + expect(result.status).toBe("done"); + expect(mockedCreate).not.toHaveBeenCalled(); + }); + + it("TC-104:L2 命中 → done + source=cache", async () => { + mockedGetL2.mockResolvedValue({ quotes: FOUR_TIERS }); + + const result = await submitQuote(VALID_BODY); + expect(result.status).toBe("done"); + expect(result.source_type).toBe("cache"); + expect(mockedEnqueue).not.toHaveBeenCalled(); + }); + + it("首次询价 → processing + 入队", async () => { + const result = await submitQuote(VALID_BODY); + expect(result.status).toBe("processing"); + expect(mockedEnqueue).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/modules/quote/quote-completeness.test.ts b/__tests__/modules/quote/quote-completeness.test.ts new file mode 100644 index 0000000..39d6830 --- /dev/null +++ b/__tests__/modules/quote/quote-completeness.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + assertFourTiers, + normalizeToFourTiers, +} from "@/modules/quote/quote-completeness"; + +const FOUR_TIERS = [ + { service_level: "standard", rate_option: "lowest" }, + { service_level: "standard", rate_option: "fastest" }, + { service_level: "guaranteed", rate_option: "lowest" }, + { service_level: "guaranteed", rate_option: "fastest" }, +]; + +describe("assertFourTiers", () => { + it("4 档 → 通过", () => { + expect(() => assertFourTiers(FOUR_TIERS)).not.toThrow(); + }); + + it("仅 2 档 lowest → normalize 后通过", () => { + expect(() => + assertFourTiers([ + { service_level: "standard", rate_option: "lowest" }, + { service_level: "guaranteed", rate_option: "lowest" }, + ]), + ).not.toThrow(); + }); + + it("缺 guaranteed 整档 → 仅含 standard/lowest 仍通过", () => { + expect(() => + assertFourTiers( + FOUR_TIERS.filter((t) => t.service_level !== "guaranteed"), + ), + ).not.toThrow(); + }); + + it("有 guaranteed/lowest 无 fastest → 通过", () => { + const incomplete = FOUR_TIERS.filter( + (t) => !(t.service_level === "guaranteed" && t.rate_option === "fastest"), + ); + expect(() => assertFourTiers(incomplete)).not.toThrow(); + }); + + it("含 bestValue 扩展档 → 保留 API 返回的全部档位", () => { + const six = [ + ...FOUR_TIERS, + { service_level: "standard", rate_option: "bestValue" }, + { service_level: "guaranteed", rate_option: "bestValue" }, + ]; + const normalized = normalizeToFourTiers(six); + expect(normalized).toHaveLength(6); + expect(() => assertFourTiers(six)).not.toThrow(); + }); + + it("2+1 动态档位全部保留", () => { + const tiers = [ + { service_level: "standard", rate_option: "lowest" }, + { service_level: "standard", rate_option: "fastest" }, + { service_level: "guaranteed", rate_option: "lowest" }, + ]; + expect(normalizeToFourTiers(tiers)).toHaveLength(3); + }); + + it("3+3 动态档位全部保留", () => { + const tiers = [ + { service_level: "standard", rate_option: "lowest" }, + { service_level: "standard", rate_option: "fastest" }, + { service_level: "standard", rate_option: "bestValue" }, + { service_level: "guaranteed", rate_option: "lowest" }, + { service_level: "guaranteed", rate_option: "fastest" }, + { service_level: "guaranteed", rate_option: "bestValue" }, + ]; + expect(normalizeToFourTiers(tiers)).toHaveLength(6); + }); +}); diff --git a/__tests__/modules/quote/quote-error-messages.test.ts b/__tests__/modules/quote/quote-error-messages.test.ts new file mode 100644 index 0000000..8cf2e4f --- /dev/null +++ b/__tests__/modules/quote/quote-error-messages.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { RpaError } from "@/modules/rpa/errors"; +import { + formatQuoteErrorMessage, + resolveQuoteFailureCode, + resolveQuoteFailureMessage, +} from "@/modules/quote/quote-error-messages"; + +describe("quote-error-messages", () => { + it("RpaError 保留细分 error_code", () => { + expect( + resolveQuoteFailureCode( + new RpaError("ADDRESS_SUGGESTION_NOT_FOUND", "未找到地址"), + ), + ).toBe("ADDRESS_SUGGESTION_NOT_FOUND"); + }); + + it("RpaError 使用原始 message 作为用户文案", () => { + const msg = resolveQuoteFailureMessage( + new RpaError("STRUCT_CHANGE", "login 页且无 Mothership 凭据"), + "QUOTE_ENTRY_UNAVAILABLE", + ); + expect(msg).toBe("login 页且无 Mothership 凭据"); + }); + + it("formatQuoteErrorMessage 优先 storedMessage", () => { + expect( + formatQuoteErrorMessage("QUOTE_UNAVAILABLE", "自定义原因"), + ).toBe("自定义原因"); + }); + + it("formatQuoteErrorMessage 按 code 回退", () => { + expect(formatQuoteErrorMessage("QUOTE_TIMEOUT")).toContain("420 秒"); + }); +}); diff --git a/__tests__/modules/quote/quote-id.test.ts b/__tests__/modules/quote/quote-id.test.ts new file mode 100644 index 0000000..d9afe44 --- /dev/null +++ b/__tests__/modules/quote/quote-id.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + generateQuoteId, + handleQuoteIdConflict, + isQuoteIdUniqueConflict, +} from "@/modules/quote/quote-id"; +import { QuoteIdConflictError } from "@/modules/quote/types"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { + findFirst: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/prisma"; + +const mockedFindFirst = vi.mocked(prisma.quoteRecord.findFirst); +const mockedAuditCreate = vi.mocked(prisma.auditLog.create); + +describe("generateQuoteId", () => { + beforeEach(() => { + mockedFindFirst.mockReset(); + }); + + it("首日生成 _0001", async () => { + mockedFindFirst.mockResolvedValue(null); + const id = await generateQuoteId(); + expect(id).toMatch(/^QTE_\d{8}_0001$/); + }); + + it("递增序号", async () => { + mockedFindFirst.mockResolvedValue({ + quoteId: "QTE_20260616_0005", + } as never); + const id = await generateQuoteId(); + expect(id).toMatch(/_0006$/); + }); +}); + +describe("isQuoteIdUniqueConflict", () => { + it("P2002 quote_id → true", () => { + expect( + isQuoteIdUniqueConflict({ + code: "P2002", + meta: { target: ["quote_id"] }, + }), + ).toBe(true); + }); + + it("其他错误 → false", () => { + expect(isQuoteIdUniqueConflict({ code: "P2002", meta: { target: ["email"] } })).toBe(false); + }); +}); + +describe("handleQuoteIdConflict", () => { + it("写 audit 并抛 QuoteIdConflictError", async () => { + mockedAuditCreate.mockResolvedValue({} as never); + await expect( + handleQuoteIdConflict("QTE_20260616_0001", "CUST_001"), + ).rejects.toThrow(QuoteIdConflictError); + expect(mockedAuditCreate).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/modules/quote/quote-serializer.test.ts b/__tests__/modules/quote/quote-serializer.test.ts new file mode 100644 index 0000000..8e6a9ff --- /dev/null +++ b/__tests__/modules/quote/quote-serializer.test.ts @@ -0,0 +1,109 @@ +import { Prisma } from "@prisma/client"; +import type { QuoteRecord } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { + serializeQuoteDetail, + serializeQuoteSummary, +} from "@/modules/quote/quote-serializer"; + +const BASE_RECORD: QuoteRecord = { + id: BigInt(1), + quoteId: "QTE_20260616_0001", + requestId: "550e8400-e29b-41d4-a716-446655440000", + customerId: "CUST_001", + cargoHash: "abc123", + status: "done", + sourceType: "cache", + isRealtime: true, + confidenceScore: null, + currency: "USD", + pickupJson: {}, + deliveryJson: {}, + weightLb: new Prisma.Decimal(500), + dimLIn: new Prisma.Decimal(48), + dimWIn: new Prisma.Decimal(40), + dimHIn: new Prisma.Decimal(48), + palletCount: 2, + cargoType: "general_freight", + quotesJson: [{ service_level: "standard" }], + markupPercent: new Prisma.Decimal(0), + validUntil: new Date(Date.now() + 60_000), + errorCode: null, + errorMessage: null, + ccCustomerId: null, + forecastId: null, + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("serializeQuoteDetail", () => { + it("done + cache → confidence_score=0.95 + quotes", () => { + const result = serializeQuoteDetail(BASE_RECORD); + expect(result.status).toBe("done"); + expect(result.confidence_score).toBe(0.95); + expect(result.source_type).toBe("cache"); + expect(result.quotes).toHaveLength(1); + }); + + it("stale → confidence_score=0.70", () => { + const result = serializeQuoteDetail({ + ...BASE_RECORD, + sourceType: "stale", + isRealtime: false, + }); + expect(result.confidence_score).toBe(0.7); + expect(result.is_realtime).toBe(false); + }); + + it("valid_until 过期 → status=expired,不含 quotes", () => { + const result = serializeQuoteDetail({ + ...BASE_RECORD, + validUntil: new Date(Date.now() - 1_000), + }); + expect(result.status).toBe("expired"); + expect(result.quotes).toBeUndefined(); + }); + + it("processing → 仅基础字段", () => { + const result = serializeQuoteDetail({ + ...BASE_RECORD, + status: "processing", + quotesJson: null, + validUntil: null, + }); + expect(result.status).toBe("processing"); + expect(result.quotes).toBeUndefined(); + }); + + it("failed → error_code + error_message", () => { + const result = serializeQuoteDetail({ + ...BASE_RECORD, + status: "failed", + errorCode: "QUOTE_TIMEOUT", + errorMessage: null, + }); + expect(result.status).toBe("failed"); + expect(result.error_code).toBe("QUOTE_TIMEOUT"); + expect(result.error_message).toContain("420 秒"); + }); + + it("failed → 优先返回库内 error_message", () => { + const result = serializeQuoteDetail({ + ...BASE_RECORD, + status: "failed", + errorCode: "QUOTE_UNAVAILABLE", + errorMessage: "MotherShip 页面加载失败", + }); + expect(result.error_message).toBe("MotherShip 页面加载失败"); + }); +}); + +describe("serializeQuoteSummary", () => { + it("历史摘要含关键字段", () => { + const result = serializeQuoteSummary(BASE_RECORD); + expect(result.quote_id).toBe("QTE_20260616_0001"); + expect(result.pallet_count).toBe(2); + expect(result.created_at).toBeTruthy(); + }); +}); diff --git a/__tests__/modules/quote/unit-converter.test.ts b/__tests__/modules/quote/unit-converter.test.ts new file mode 100644 index 0000000..787f70c --- /dev/null +++ b/__tests__/modules/quote/unit-converter.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { cmToIn, kgToLb } from "@/modules/quote/unit-converter"; + +describe("unit-converter", () => { + it("227kg → 500.45lb(ROUND_HALF_UP,JS 浮点 227×2.20462)", () => { + expect(kgToLb(227)).toBe(500.45); + }); + + it("122cm → 48.03in", () => { + expect(cmToIn(122)).toBe(48.03); + }); + + it("500lb 不变", () => { + expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0); + }); +}); diff --git a/__tests__/modules/quote/validation.test.ts b/__tests__/modules/quote/validation.test.ts new file mode 100644 index 0000000..bfb50b0 --- /dev/null +++ b/__tests__/modules/quote/validation.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { + MOTHERSHIP_LIMITS, + validateQuoteInput, + rejectQuantityField, +} from "@/modules/quote/validation"; +import { ValidationError } from "@/modules/quote/types"; +import { + MOTHERSHIP_DELIVERY_CONFIRMED, + MOTHERSHIP_PICKUP_CONFIRMED, +} from "@/__tests__/fixtures/mothership-address"; + +const VALID_BODY = { + request_id: "550e8400-e29b-41d4-a716-446655440000", + customer_id: "CUST_001", + pickup_address: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + place_id: "ChIJ_pickup", + formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_PICKUP_CONFIRMED, + }, + delivery_address: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + place_id: "ChIJ_delivery", + formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA", + selected_from_suggestions: true, + ...MOTHERSHIP_DELIVERY_CONFIRMED, + }, + weight: { value: 500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + pallet_count: 2, + cargo_type: "general_freight", +}; + +describe("validateQuoteInput", () => { + it("正常入参通过", () => { + const result = validateQuoteInput(VALID_BODY); + expect(result.cargoHash).toHaveLength(32); + expect(result.weightLb).toBe(500); + }); + + it("TC-201:weight 缺失 → VALIDATION_FAILED", () => { + const { weight: _w, ...rest } = VALID_BODY; + expect(() => validateQuoteInput(rest)).toThrow(ValidationError); + }); + + it("TC-202:weight=0 → 超范围", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + weight: { value: 0, unit: "lb" }, + }), + ).toThrow(/超出 Mothership 允许范围/); + }); + + it("空邮编通过(MotherShip 不要求填写邮编)", () => { + const result = validateQuoteInput({ + ...VALID_BODY, + pickup_address: { ...VALID_BODY.pickup_address, zip: "" }, + delivery_address: { ...VALID_BODY.delivery_address, zip: "" }, + }); + expect(result.cargoHash).toHaveLength(32); + }); + + it("TC-203:zip 4 位 → 无效", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + pickup_address: { + ...VALID_BODY.pickup_address, + zip: "9001", + }, + }), + ).toThrow(/邮编/); + }); + + it("TC-204:request_id 非 UUID → 无效", () => { + expect(() => + validateQuoteInput({ ...VALID_BODY, request_id: "not-uuid" }), + ).toThrow("请求标识无效"); + }); + + it("E4.3:selected_from_suggestions=false", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + pickup_address: { + ...VALID_BODY.pickup_address, + selected_from_suggestions: false, + }, + }), + ).toThrow(ValidationError); + }); + + it("未确认 MotherShip 精确地址 → 拒绝询价", () => { + const { mothership_option_id: _a, mothership_display_label: _b, selected_from_mothership: _c, ...pickup } = + VALID_BODY.pickup_address; + expect(() => + validateQuoteInput({ + ...VALID_BODY, + pickup_address: pickup, + }), + ).toThrow(ValidationError); + }); + + it("E4.7:quantity 字段拒绝", () => { + expect(() => + rejectQuantityField({ ...VALID_BODY, quantity: 5 }), + ).toThrow("请使用托盘数 pallet_count"); + }); + + it("E4.6:pallet_count 超 25", () => { + expect(() => + validateQuoteInput({ ...VALID_BODY, pallet_count: 26 }), + ).toThrow(/托盘数超出/); + }); + + it("E4.6:单托重量超 9999", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + weight: { value: 10_000, unit: "lb" }, + }), + ).toThrow(/重量超出/); + }); + + it("E4.6:长度超 999 in", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + dimensions: { length: 1000, width: 40, height: 48, unit: "in" }, + }), + ).toThrow(/尺寸超出/); + }); + + it("边界:25 托、9999 lb、999×99×99 in 通过", () => { + const result = validateQuoteInput({ + ...VALID_BODY, + weight: { value: 9_999, unit: "lb" }, + dimensions: { length: 999, width: 99, height: 99, unit: "in" }, + pallet_count: 25, + }); + expect(result.palletCount).toBe(25); + }); + + it("SQL 注入拦截", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + pickup_address: { + ...VALID_BODY.pickup_address, + street: "'; DROP TABLE users; --", + }, + }), + ).toThrow(/无效,请检查/); + }); + + it("kg/cm 换算", () => { + const result = validateQuoteInput({ + ...VALID_BODY, + weight: { value: 227, unit: "kg" }, + dimensions: { length: 120, width: 100, height: 100, unit: "cm" }, + }); + expect(result.weightLb).toBe(500.45); + expect(result.dimLIn).toBe(47.24); + }); +}); + +describe("MOTHERSHIP_LIMITS", () => { + it("托盘 1-25", () => { + expect(MOTHERSHIP_LIMITS.palletCount.max).toBe(25); + }); +}); diff --git a/__tests__/modules/rpa/error-mapper.test.ts b/__tests__/modules/rpa/error-mapper.test.ts new file mode 100644 index 0000000..ad36017 --- /dev/null +++ b/__tests__/modules/rpa/error-mapper.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + mapPageTextToErrorCode, + mapUrlToErrorCode, +} from "@/modules/rpa/error-mapper"; + +describe("error-mapper", () => { + it("无运力页 → CARRIER_NO_CAPACITY", () => { + expect(mapPageTextToErrorCode("No carriers available for this lane")).toBe( + "CARRIER_NO_CAPACITY", + ); + }); + + it("不支持邮编 → ADDRESS_NOT_SUPPORTED", () => { + expect(mapPageTextToErrorCode("This zip code is not supported")).toBe( + "ADDRESS_NOT_SUPPORTED", + ); + }); + + it("验证码 URL → RPA_CAPTCHA", () => { + expect(mapUrlToErrorCode("https://example.com/captcha")).toBe( + "RPA_CAPTCHA", + ); + }); + + it("login URL 无凭据 → null(由 adapter 判 STRUCT_CHANGE)", () => { + delete process.env.MOTHERSHIP_EMAIL; + delete process.env.MOTHERSHIP_PASSWORD; + expect(mapUrlToErrorCode("https://example.com/login")).toBe(null); + }); + + it("login URL 有凭据 → SESSION_EXPIRED", () => { + process.env.MOTHERSHIP_EMAIL = "test@example.com"; + process.env.MOTHERSHIP_PASSWORD = "secret"; + expect(mapUrlToErrorCode("https://example.com/login")).toBe( + "SESSION_EXPIRED", + ); + }); +}); diff --git a/__tests__/pricing/recalc-from-db.test.ts b/__tests__/pricing/recalc-from-db.test.ts new file mode 100644 index 0000000..9634ee2 --- /dev/null +++ b/__tests__/pricing/recalc-from-db.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + applyMarkup, + applyMarkupToQuotes, + type RawQuoteTier, +} from "@/modules/pricing/engine"; + +const RAW_TIERS: RawQuoteTier[] = [ + { + service_level: "standard", + rate_option: "lowest", + carrier: "Mothership", + transit_days: "5-7", + transit_description: "最低价格", + raw_freight: 320, + surcharges: 15, + raw_total: 335, + }, + { + service_level: "standard", + rate_option: "fastest", + carrier: "Mothership", + transit_days: "3-4", + transit_description: "最快", + raw_freight: 365, + surcharges: 0, + raw_total: 365, + }, + { + service_level: "guaranteed", + rate_option: "lowest", + carrier: "Mothership", + transit_days: "4-5", + transit_description: "保价最低", + raw_freight: 380, + surcharges: 0, + raw_total: 380, + }, + { + service_level: "guaranteed", + rate_option: "fastest", + carrier: "Mothership", + transit_days: "2-3", + transit_description: "保价最快", + raw_freight: 410, + surcharges: 0, + raw_total: 410, + }, +]; + +describe("TC-403 加价可复算(task-114)", () => { + it("从 DB raw_freight + markup 重算 final_total 误差 ≤0.01", () => { + const markupPercent = 10; + const marked = applyMarkupToQuotes(RAW_TIERS, markupPercent); + + for (let i = 0; i < RAW_TIERS.length; i++) { + const raw = RAW_TIERS[i]; + const expected = applyMarkup( + raw.raw_freight, + raw.raw_total, + markupPercent, + ); + const actual = marked[i]; + expect(Math.abs(actual.final_total - expected.finalTotal)).toBeLessThanOrEqual( + 0.01, + ); + expect(Math.abs(actual.markup_amount - expected.markupAmount)).toBeLessThanOrEqual( + 0.01, + ); + } + }); + + it("TC-103 精确到分:markup_amount=32.00 final_total=367.00", () => { + const result = applyMarkup(320, 335, 10); + expect(result.markupAmount).toBe(32); + expect(result.finalTotal).toBe(367); + }); +}); diff --git a/__tests__/scripts/human-commit-baseline-paths.test.ts b/__tests__/scripts/human-commit-baseline-paths.test.ts new file mode 100644 index 0000000..0651cad --- /dev/null +++ b/__tests__/scripts/human-commit-baseline-paths.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { createRunPaths } from "@/scripts/human-commit-baseline/paths"; + +describe("human-commit-baseline paths", () => { + it("为 runId 生成完整产物路径", () => { + const p = createRunPaths("abc12345"); + expect(p.root).toContain("human-commit-baseline"); + expect(p.root).toContain("abc12345"); + expect(p.har).toContain("baseline.har"); + expect(p.trace).toContain("trace.zip"); + expect(p.triggerAfterPickup).toContain("after-pickup"); + }); +}); diff --git a/__tests__/scripts/human-rpa-network-diff.test.ts b/__tests__/scripts/human-rpa-network-diff.test.ts new file mode 100644 index 0000000..4f07910 --- /dev/null +++ b/__tests__/scripts/human-rpa-network-diff.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + diffHumanRpaNetwork, + summarizeNetwork, + type NetworkEvent, +} from "@/scripts/human-commit-baseline/network-diff"; + +const humanSample: NetworkEvent[] = [ + { + ts: 100, + iso: "", + phase: "page_loaded", + kind: "request", + method: "POST", + url: "https://services.mothership.com/axel/location/search", + isLocationSearch: true, + }, + { + ts: 200, + iso: "", + phase: "page_loaded", + kind: "response", + method: "POST", + url: "https://services.mothership.com/axel/location/search", + status: 200, + isLocationSearch: true, + }, + { + ts: 900, + iso: "", + phase: "page_loaded", + kind: "request", + method: "GET", + url: "https://services.mothership.com/axel/location/place/ChIJabc", + isPlaceCommit: true, + }, + { + ts: 1100, + iso: "", + phase: "page_loaded", + kind: "response", + method: "GET", + url: "https://services.mothership.com/axel/location/place/ChIJabc", + status: 200, + isPlaceCommit: true, + }, +]; + +const rpaMissingPlace: NetworkEvent[] = [ + { + ts: 100, + iso: "", + phase: "rpa_pickup", + kind: "request", + method: "POST", + url: "https://services.mothership.com/axel/location/search", + isLocationSearch: true, + }, + { + ts: 200, + iso: "", + phase: "rpa_pickup", + kind: "response", + method: "POST", + url: "https://services.mothership.com/axel/location/search", + status: 200, + isLocationSearch: true, + }, +]; + +describe("network-diff", () => { + it("真人 sample 识别 search→place 完整链", () => { + const s = summarizeNetwork(humanSample, "human"); + expect(s.placeGet200).toBe(1); + expect(s.chains[0]?.complete).toBe(true); + expect(s.chains[0]?.gapSearchResponseToPlaceRequestMs).toBe(700); + }); + + it("RPA 缺 place 时 verdict=RPA_MISSING_PLACE", () => { + const humanFull = [...humanSample, ...humanSample.map((e, i) => ({ ...e, ts: e.ts + 2000 + i }))]; + const d = diffHumanRpaNetwork(humanFull, rpaMissingPlace, "human1", "rpa1"); + expect(d.verdict).toBe("RPA_MISSING_PLACE"); + expect(d.gaps.some((g) => g.includes("place"))).toBe(true); + }); +}); diff --git a/__tests__/scripts/lib/axel-event-tracer.test.ts b/__tests__/scripts/lib/axel-event-tracer.test.ts new file mode 100644 index 0000000..ff948b2 --- /dev/null +++ b/__tests__/scripts/lib/axel-event-tracer.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + diffAxelTraces, + normalizeAxelSequence, + type AxelTraceEvent, +} from "../../../scripts/lib/axel-event-tracer"; + +describe("axel-event-tracer", () => { + it("diff 识别 RPA 缺失 place commit", () => { + const manual: AxelTraceEvent[] = [ + { + at: 1, + phase: "manual-pickup", + method: "POST", + url: "https://services.mothership.com/axel/location/search", + status: 200, + kind: "search", + searchPlaceIds: ["ChIJ_manual"], + }, + { + at: 2, + phase: "manual-pickup", + method: "GET", + url: "https://services.mothership.com/axel/location/place/ChIJ_manual", + status: 200, + kind: "place", + placeId: "ChIJ_manual", + }, + ]; + const rpa: AxelTraceEvent[] = [ + { + at: 1, + phase: "rpa-fillAddress", + method: "POST", + url: "https://services.mothership.com/axel/location/search", + status: 200, + kind: "search", + searchPlaceIds: ["ChIJ_manual"], + }, + ]; + const diff = diffAxelTraces(manual, rpa); + expect(diff.verdict).toBe("RPA_MISSING_PLACE_COMMIT"); + expect(diff.manualPlaceCommits).toEqual(["ChIJ_manual"]); + expect(diff.rpaPlaceCommits).toEqual([]); + expect(normalizeAxelSequence(manual).some((l) => l.includes("GET place/ChIJ_manual"))).toBe( + true, + ); + }); +}); diff --git a/__tests__/scripts/phase0/verify-baseline.test.ts b/__tests__/scripts/phase0/verify-baseline.test.ts new file mode 100644 index 0000000..61ebefc --- /dev/null +++ b/__tests__/scripts/phase0/verify-baseline.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + countAxelQuoteTiers, + extractAxelQuoteContract, +} from "@/scripts/phase0/lib/axel-quote-contract"; +import { pickBestQuoteEntry, scoreQuoteBody } from "@/scripts/phase0/lib/har-parse"; +import { buildQuoteContractSchema } from "@/scripts/phase0/lib/schema-infer"; + +const AXEL_SAMPLE = { + data: { + rates: { + standard: { + lowest: { price: 100, days: 5, serviceType: "lowest" }, + fastest: { price: 200, days: 2, serviceType: "fastest" }, + bestValue: { price: 150, days: 3, serviceType: "bestValue" }, + }, + guaranteed: { + bestValue: { price: 250, days: 2, serviceType: "bestValue" }, + }, + }, + availableRates: { + rate_a: { price: 100, serviceLevel: "standard", serviceType: "lowest" }, + rate_b: { price: 200, serviceLevel: "standard", serviceType: "fastest" }, + rate_c: { price: 110, serviceLevel: "guaranteed", serviceType: "lowest" }, + rate_d: { price: 220, serviceLevel: "guaranteed", serviceType: "fastest" }, + }, + }, +}; + +describe("phase0 axel-quote-contract", () => { + it("识别 data.rates 四档与 availableRates", () => { + const c = extractAxelQuoteContract(AXEL_SAMPLE); + expect(c.fourTierCoverage.standardLowest).toBe(true); + expect(c.fourTierCoverage.standardFastest).toBe(true); + expect(c.availableRateCount).toBe(4); + expect(countAxelQuoteTiers(AXEL_SAMPLE).pricedLeafCount).toBeGreaterThanOrEqual(4); + }); +}); + +describe("phase0 har-parse", () => { + it("pickBestQuoteEntry 识别 axel/quote 响应", () => { + const har = { + log: { + entries: [ + { + request: { + url: "https://services.mothership.com/axel/quote", + method: "POST", + }, + response: { + status: 200, + content: { + mimeType: "application/json", + text: JSON.stringify(AXEL_SAMPLE), + }, + }, + }, + ], + }, + }; + const picked = pickBestQuoteEntry(har.log!.entries!); + expect(picked).not.toBeNull(); + expect(scoreQuoteBody(picked!.body)).toBeGreaterThanOrEqual(5); + }); +}); + +describe("phase0 schema-infer", () => { + it("buildQuoteContractSchema 产出含 body 与 sourceHar", () => { + const doc = buildQuoteContractSchema({ + body: AXEL_SAMPLE, + harEntry: { + url: "https://services.mothership.com/axel/quote", + method: "POST", + status: 200, + }, + }); + expect(doc.$schema).toContain("json-schema.org"); + expect(doc.sourceHar.url).toContain("axel/quote"); + expect(doc.body.type).toBe("object"); + }); +}); diff --git a/__tests__/scripts/pick-mothership-candidate.test.ts b/__tests__/scripts/pick-mothership-candidate.test.ts new file mode 100644 index 0000000..56c883d --- /dev/null +++ b/__tests__/scripts/pick-mothership-candidate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { pickBestMothershipCandidate } from "@/scripts/lib/pick-mothership-candidate"; + +describe("pickBestMothershipCandidate", () => { + it("优先匹配门牌号与城市,惩罚 Bridge 歧义", () => { + const picked = pickBestMothershipCandidate( + [ + { + option_id: "ChIJ-bridge", + display_label: "1234 Market Street Bridge, Philadelphia, PA, USA", + formatted_address: "1234 Market Street Bridge, Philadelphia, PA, USA", + street: "1234 Market Street Bridge", + city: "Philadelphia", + state: "PA", + zip: "", + }, + { + option_id: "ChIJ-good", + display_label: "1234 Market Street, Philadelphia, PA, USA", + formatted_address: "1234 Market Street, Philadelphia, PA, USA", + street: "1234 Market Street", + city: "Philadelphia", + state: "PA", + zip: "", + }, + ], + { street: "1234 Market Street", city: "Philadelphia", state: "PA" }, + ); + expect(picked?.option_id).toBe("ChIJ-good"); + }); +}); diff --git a/__tests__/scripts/record-mothership.test.ts b/__tests__/scripts/record-mothership.test.ts new file mode 100644 index 0000000..f0760c5 --- /dev/null +++ b/__tests__/scripts/record-mothership.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.sh"); + +describe("record-mothership.sh (task-121)", () => { + const content = readFileSync(SCRIPT_PATH, "utf8"); + + it("存在且包含 headed 与 codegen 启动", () => { + expect(content).toContain("RPA_HEADLESS=false"); + expect(content).toContain("playwright"); + expect(content).toContain("codegen"); + }); + + it("禁止 GD-15 黑名单 URL", () => { + expect(content).toContain("dashboard.mothership.com"); + expect(content).toContain("www.mothership.com/quote"); + expect(content).toContain("check_url"); + }); + + it("产出录制脚本与 storageState 路径", () => { + expect(content).toContain(".rpa/recordings"); + expect(content).toContain("mothership-storage.json"); + expect(content).toContain("--save-storage"); + }); +}); + +const TS_SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.ts"); + +describe("record-mothership.ts (Windows 跨平台)", () => { + const content = readFileSync(TS_SCRIPT_PATH, "utf8"); + + it("存在且包含 headed codegen 与 GD-15 校验", () => { + expect(content).toContain('RPA_HEADLESS: "false"'); + expect(content).toContain("playwright"); + expect(content).toContain("codegen"); + expect(content).toContain("isForbiddenQuoteUrl"); + }); + + it("产出录制脚本与 storageState 路径", () => { + expect(content).toContain(".rpa/recordings"); + expect(content).toContain("mothership-storage.json"); + expect(content).toContain("--save-storage"); + }); +}); diff --git a/__tests__/workers/rpa/address-adapter-diagnostics.test.ts b/__tests__/workers/rpa/address-adapter-diagnostics.test.ts new file mode 100644 index 0000000..53d09be --- /dev/null +++ b/__tests__/workers/rpa/address-adapter-diagnostics.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { buildNetworkDiagnostics } from "@/workers/rpa/address-adapter/diagnostics"; + +describe("address-adapter diagnostics", () => { + it("buildNetworkDiagnostics 要求 place 200 才算 httpOk", () => { + const diag = buildNetworkDiagnostics( + { + getObservedPlaceCommitIds: () => ["ChIJ_pickup", "ChIJ_delivery"], + getPlaceNetworkLog: () => [ + { + placeId: "ChIJ_delivery", + status: 200, + url: "https://services.mothership.com/axel/location/place/ChIJ_delivery", + at: 1, + }, + ], + hasPlaceCommit: (id) => id === "ChIJ_delivery", + hasSuccessfulPlaceNetworkRequest: (id) => id === "ChIJ_delivery", + }, + "ChIJ_delivery", + "ChIJ_pickup", + ); + + expect(diag.placeRequestHttpOk).toBe(true); + expect(diag.pickupLostAfterDelivery).toBe(false); + expect(diag.placeNetworkEvents).toHaveLength(1); + }); + + it("buildNetworkDiagnostics 不在 bridge 层判定 pickupLost(改由 widget DOM)", () => { + const diag = buildNetworkDiagnostics( + { + getObservedPlaceCommitIds: () => ["ChIJ_delivery"], + getPlaceNetworkLog: () => [ + { + placeId: "ChIJ_delivery", + status: 200, + url: "", + at: 1, + }, + ], + hasPlaceCommit: () => true, + hasSuccessfulPlaceNetworkRequest: () => true, + }, + "ChIJ_delivery", + "ChIJ_pickup", + ); + + expect(diag.pickupLostAfterDelivery).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/address-adapter.test.ts b/__tests__/workers/rpa/address-adapter.test.ts new file mode 100644 index 0000000..00bcd56 --- /dev/null +++ b/__tests__/workers/rpa/address-adapter.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { AddressCommitStateMachine } from "@/workers/rpa/address-adapter/commit-state-machine"; +import { formatDiagnosticsFailure } from "@/workers/rpa/address-adapter/diagnostics"; +import { getRpaAddressMode } from "@/lib/rpa/address-mode"; + +describe("AddressCommitStateMachine", () => { + it("COMMITTED 可进入下游", () => { + const sm = new AddressCommitStateMachine(); + sm.transition("COMMITTED"); + expect(sm.canProceedToCargo()).toBe(true); + expect(() => sm.assertCommittedForDownstream("pickup")).not.toThrow(); + }); + + it("FAILED 禁止进入 cargo", () => { + const sm = new AddressCommitStateMachine(); + sm.transition("FAILED"); + expect(sm.canProceedToCargo()).toBe(false); + expect(() => sm.assertCommittedForDownstream("pickup")).toThrow(/COMMITTED/); + }); +}); + +describe("formatDiagnosticsFailure", () => { + it("区分 no_place_request", () => { + const msg = formatDiagnosticsFailure({ + address: "123 Main", + placeId: "ChIJ_x", + method: "keyboard", + events: [], + placeRequestObserved: false, + status: "no_place_request", + commitState: "FAILED", + }); + expect(msg).toContain("未发起 GET place 请求"); + expect(msg).toContain("selection event"); + }); +}); + +describe("getRpaAddressMode", () => { + it("默认 real", () => { + const prev = process.env.RPA_ADDRESS_MODE; + delete process.env.RPA_ADDRESS_MODE; + expect(getRpaAddressMode()).toBe("real"); + process.env.RPA_ADDRESS_MODE = prev; + }); + + it("mock 显式启用", () => { + const prev = process.env.RPA_ADDRESS_MODE; + process.env.RPA_ADDRESS_MODE = "mock"; + expect(getRpaAddressMode()).toBe("mock"); + process.env.RPA_ADDRESS_MODE = prev; + }); +}); diff --git a/__tests__/workers/rpa/address-commit.test.ts b/__tests__/workers/rpa/address-commit.test.ts new file mode 100644 index 0000000..aeb352f --- /dev/null +++ b/__tests__/workers/rpa/address-commit.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import type { QuoteRequest } from "@/modules/providers/quote-provider"; +import { + evaluateAddressesVisuallyComplete, + widgetContainsConfirmedAddress, +} from "@/workers/rpa/address-commit"; + +function basePickup(overrides?: Partial) { + return { + street: "1234 Warehouse Street", + city: "Farmers Branch", + state: "TX", + zip: "75234", + placeId: "p1", + formattedAddress: "1234 Warehouse Street Farmers Branch TX", + selectedFromSuggestions: true, + mothershipOptionId: "o1", + mothershipDisplayLabel: "1234 Warehouse Road Farmers Branch TX", + selectedFromMothership: true, + ...overrides, + }; +} + +describe("widgetContainsConfirmedAddress", () => { + it("匹配门牌号 + 州", () => { + const text = + "1234 Warehouse Road Farmers Branch, TX 5678 Distribution Drive Fort Worth, TX"; + expect(widgetContainsConfirmedAddress(text, basePickup())).toBe(true); + }); + + it("错误 street 不匹配", () => { + const text = "999 Wrong Street Fort Worth, TX"; + expect(widgetContainsConfirmedAddress(text, basePickup())).toBe(false); + }); + + it("多词城市须完整匹配(West Seattle)", () => { + const address = basePickup({ + street: "3131 Western Avenue", + city: "West Seattle", + state: "WA", + mothershipDisplayLabel: "3131 Western AvenueWest Seattle, WA", + }); + expect( + widgetContainsConfirmedAddress( + "3131 Western Avenue West Seattle, WA", + address, + ), + ).toBe(true); + expect( + widgetContainsConfirmedAddress( + "3131 Western Avenue, Seattle, WA, USA", + address, + ), + ).toBe(false); + expect( + widgetContainsConfirmedAddress("3131 Western Ave Seattle, WA", address), + ).toBe(false); + }); + + it("确认标签无门牌号时不强制 widget 含用户输入门牌号", () => { + const address = basePickup({ + street: "5678 Distribution Way", + city: "Farmers Branch", + state: "TX", + mothershipDisplayLabel: "Distribution Way, Farmers Branch, TX, USA", + }); + const widget = + "1234 Warehouse Street Los Angeles, CA Distribution Way Farmers Branch, TX"; + expect(widgetContainsConfirmedAddress(widget, address)).toBe(true); + }); +}); + +describe("evaluateAddressesVisuallyComplete", () => { + it("截图态:双州码 + Freight details 视为地址完成", () => { + const text = + "Seattle, WA 293 East Pike Street Seattle, WA Pelican Hotel Miami Beach, FL Freight details Get a freight quote"; + expect(evaluateAddressesVisuallyComplete(text)).toBe(true); + }); + + it("仅单地址不算完成", () => { + expect(evaluateAddressesVisuallyComplete("293 East Pike Street Seattle, WA")).toBe( + false, + ); + }); +}); diff --git a/__tests__/workers/rpa/address-side.test.ts b/__tests__/workers/rpa/address-side.test.ts new file mode 100644 index 0000000..6ad497d --- /dev/null +++ b/__tests__/workers/rpa/address-side.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Locator, Page } from "playwright"; +import { visibleStreetInput } from "@/workers/rpa/address-side"; + +function mockTextbox(isEditable: boolean, visible = true): Locator { + const loc = { + isVisible: vi.fn().mockResolvedValue(visible), + isEditable: vi.fn().mockResolvedValue(isEditable), + isDisabled: vi.fn().mockResolvedValue(false), + getAttribute: vi.fn().mockResolvedValue(null), + inputValue: vi.fn().mockResolvedValue(""), + } as unknown as Locator; + return loc; +} + +function mockPageWithTextboxes(boxes: Locator[]): Page { + const widget = { + getByRole: vi.fn().mockReturnValue({ + count: vi.fn().mockResolvedValue(boxes.length), + nth: (i: number) => boxes[i], + }), + }; + return { + locator: vi.fn().mockReturnValue({ + first: () => widget, + }), + } as unknown as Page; +} + +describe("visibleStreetInput", () => { + it("派送:仅 1 个 textbox 时返回 null,强制先点送货至", async () => { + const page = mockPageWithTextboxes([mockTextbox(false)]); + const result = await visibleStreetInput(page, "delivery"); + expect(result).toBeNull(); + }); + + it("派送:分区已打开且仅 1 个含提货文案的 textbox 时返回 null", async () => { + const pickupBox = mockTextbox(true); + (pickupBox as { inputValue: ReturnType }).inputValue = vi + .fn() + .mockResolvedValue("1234 Warehouse Street, Los Angeles, CA, USA"); + const page = mockPageWithTextboxes([pickupBox]); + const result = await visibleStreetInput(page, "delivery", { + deliverySectionOpened: true, + }); + expect(result).toBeNull(); + }); + + it("派送:分区已打开时取最后一个可编辑框", async () => { + const page = mockPageWithTextboxes([ + mockTextbox(false), + mockTextbox(true), + ]); + const result = await visibleStreetInput(page, "delivery", { + deliverySectionOpened: true, + }); + expect(result).toBeTruthy(); + }); + + it("提货:单 textbox 可编辑时直接返回", async () => { + const page = mockPageWithTextboxes([mockTextbox(true)]); + const result = await visibleStreetInput(page, "pickup"); + expect(result).toBeTruthy(); + }); +}); diff --git a/__tests__/workers/rpa/address-step.test.ts b/__tests__/workers/rpa/address-step.test.ts new file mode 100644 index 0000000..e18f551 --- /dev/null +++ b/__tests__/workers/rpa/address-step.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { Page } from "playwright"; +import type { QuoteRequest } from "@/modules/providers/quote-provider"; +import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step"; +import type { RPAContext } from "@/workers/rpa/kernel/context"; + +const mockSelectAddress = vi.fn(); +const mockWaitForCommit = vi.fn(); +const mockGetDiagnostics = vi.fn(); +const mockGetCommitState = vi.fn(); + +vi.mock("@/workers/rpa/address-adapter", () => ({ + createAddressAdapter: vi.fn(() => ({ + selectAddress: mockSelectAddress, + waitForCommit: mockWaitForCommit, + getCommittedAddress: vi.fn(), + getDiagnostics: mockGetDiagnostics, + getCommitState: mockGetCommitState, + })), + formatDiagnosticsFailure: vi.fn((d) => d.status ?? "failed"), +})); + +vi.mock("@/lib/rpa/address-mode", () => ({ + isRpaAddressMockMode: vi.fn().mockReturnValue(false), + getRpaAddressMode: vi.fn().mockReturnValue("real"), +})); + +vi.mock("@/workers/rpa/address-commit", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + blurAddressEditing: vi.fn().mockResolvedValue(undefined), + dismissAddressSuggestionDropdown: vi.fn().mockResolvedValue(undefined), + settlePickupBeforeDelivery: vi.fn().mockResolvedValue(undefined), + assertDualAddressStable: vi.fn().mockResolvedValue(undefined), + ensureDualAddressCommitted: vi.fn().mockResolvedValue(undefined), + isPickupReadyForDeliveryInput: vi.fn().mockResolvedValue(true), + lockPickupIntoChip: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("@/workers/rpa/address-suggestions", () => ({ + isAddressSuggestionDropdownOpen: vi.fn().mockResolvedValue(false), + getAddressConfirmationSurfaceText: vi.fn().mockResolvedValue(""), +})); + +vi.mock("@/workers/rpa/address-side", () => ({ + openAddressSide: vi.fn().mockResolvedValue(undefined), + visibleStreetInput: vi.fn(), +})); + +vi.mock("@/workers/rpa/cargo-lock", () => ({ + settleAddressFieldsForCargo: vi.fn().mockResolvedValue(undefined), + assertBothAddressesLocked: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/workers/rpa/kernel/step-guard", () => ({ + runKernelSubStep: vi.fn( + (_step: string, _label: string, fn: () => Promise) => fn(), + ), +})); + +vi.mock("@/workers/rpa/page-prep", () => ({ + stabilizeQuoteLandingPage: vi.fn().mockResolvedValue(undefined), +})); + +import { openAddressSide } from "@/workers/rpa/address-side"; + +function baseRequest(overrides?: Partial): QuoteRequest { + return { + cargoHash: "hash", + quoteSessionId: undefined, + pickup: { + street: "1234 Warehouse Street", + city: "Farmers Branch", + state: "TX", + zip: "75234", + placeId: "p1", + formattedAddress: "1234 Warehouse Street Farmers Branch TX", + selectedFromSuggestions: true, + mothershipOptionId: "o1", + mothershipDisplayLabel: "1234 Warehouse Road Farmers Branch TX", + selectedFromMothership: true, + }, + delivery: { + street: "5678 Distribution Way", + city: "Fort Worth", + state: "TX", + zip: "76106", + placeId: "d1", + formattedAddress: "5678 Distribution Way Fort Worth TX", + selectedFromSuggestions: true, + mothershipOptionId: "o2", + mothershipDisplayLabel: "5678 Distribution Drive Fort Worth TX", + selectedFromMothership: true, + }, + weightLb: 500, + dimsIn: { l: 48, w: 40, h: 48 }, + palletCount: 2, + cargoType: "general_freight", + ...overrides, + }; +} + +function mockCtx(widgetText = ""): RPAContext { + const page = { + keyboard: { press: vi.fn().mockResolvedValue(undefined) }, + waitForTimeout: vi.fn().mockResolvedValue(undefined), + locator: vi.fn().mockReturnValue({ + innerText: vi.fn().mockResolvedValue(widgetText), + }), + url: vi.fn().mockReturnValue("https://example.com"), + } as unknown as Page; + + return { + page, + selectors: { + resolve: vi.fn().mockReturnValue("#widget"), + specs: vi.fn().mockReturnValue([]), + widget: vi.fn(), + }, + exec: { evaluateOnLocator: vi.fn() }, + runtime: { evaluateOnLocator: vi.fn() }, + getWidgetScrollAt: vi.fn().mockReturnValue(0), + setWidgetScrollAt: vi.fn(), + state: { + markAddressSide: vi.fn(), + settleAddressEditing: vi.fn().mockResolvedValue(undefined), + axelPlaceLocations: { pickup: { placeId: "ChIJ_test" } }, + }, + } as unknown as RPAContext; +} + +describe("fillAddressStep", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectAddress.mockImplementation(async (params) => { + params.ctx.state.axelPlaceLocations[params.side] = { + placeId: "ChIJ_test", + }; + return { + success: true, + placeId: "ChIJ_test", + formattedAddress: "test", + committed: true, + }; + }); + mockWaitForCommit.mockResolvedValue({ + success: true, + placeId: "ChIJ_test", + formattedAddress: "test", + committed: true, + }); + mockGetCommitState.mockReturnValue("COMMITTED"); + mockGetDiagnostics.mockReturnValue({ + address: "test", + placeId: "ChIJ_test", + method: "mothership", + events: [], + placeRequestObserved: true, + status: "ok", + commitState: "COMMITTED", + }); + }); + + it("有 quote_session 时仍走完整填表(禁止跳过)", async () => { + const ctx = mockCtx( + "1234 Warehouse Road Farmers Branch TX 5678 Distribution Drive Fort Worth TX", + ); + const loc = { + isEditable: vi.fn().mockResolvedValue(true), + waitFor: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + }; + const { visibleStreetInput } = await import("@/workers/rpa/address-side"); + vi.mocked(visibleStreetInput).mockResolvedValue(loc as never); + + await fillAddressStep( + ctx, + baseRequest({ quoteSessionId: "session-uuid" }), + ); + + expect(openAddressSide).toHaveBeenCalled(); + expect(mockSelectAddress).toHaveBeenCalled(); + }); + + it("无 quote_session 时走完整填表", async () => { + const ctx = mockCtx( + "1234 Warehouse Road Farmers Branch TX 5678 Distribution Drive Fort Worth TX", + ); + const loc = { + isEditable: vi.fn().mockResolvedValue(true), + waitFor: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + }; + const { visibleStreetInput } = await import("@/workers/rpa/address-side"); + vi.mocked(visibleStreetInput).mockResolvedValue(loc as never); + + await fillAddressStep(ctx, baseRequest()); + + expect(openAddressSide).toHaveBeenCalled(); + expect(mockSelectAddress).toHaveBeenCalledTimes(2); + }); + + it("widget 未体现确认地址时抛错", async () => { + const { assertDualAddressStable } = await import( + "@/workers/rpa/address-commit" + ); + vi.mocked(assertDualAddressStable).mockRejectedValueOnce({ + code: "ADDRESS_NOT_CONFIRMED", + }); + const ctx = mockCtx("999 Wrong Street Fort Worth TX"); + const loc = { + isEditable: vi.fn().mockResolvedValue(true), + waitFor: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + }; + const { visibleStreetInput } = await import("@/workers/rpa/address-side"); + vi.mocked(visibleStreetInput).mockResolvedValue(loc as never); + + await expect(fillAddressStep(ctx, baseRequest())).rejects.toMatchObject({ + code: "ADDRESS_NOT_CONFIRMED", + }); + }); +}); diff --git a/__tests__/workers/rpa/address-suggestions.test.ts b/__tests__/workers/rpa/address-suggestions.test.ts new file mode 100644 index 0000000..048e4e9 --- /dev/null +++ b/__tests__/workers/rpa/address-suggestions.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it } from "vitest"; +import { + addressInputMatchesQuery, + arrowDownCountForPacIndex, + buildAddressAutocompleteQueries, + buildAddressBindingLabels, + buildAddressEnumerationQueries, + extractStreetFromLabel, + formatAddressQuery, + formatMothershipFormattedAddress, + isMotherShipEchoCandidate, + isMotherShipEnumerationRowForTest, + labelToCandidate, + resolveAddressAutocompleteInput, + scoreSuggestionLabelMatch, +} from "@/workers/rpa/address-suggestions"; + +const laQuery = { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", +}; + +describe("address-suggestions", () => { + it("addressInputMatchesQuery:已有等效文案时不应再 fill 清空", () => { + expect( + addressInputMatchesQuery( + "293 Pike St, Seattle, Washington", + "293 Pike St, Seattle, WA, USA", + ), + ).toBe(true); + expect(addressInputMatchesQuery("", "293 Pike St")).toBe(false); + expect( + addressInputMatchesQuery("123 Main", "456 Oak Ave, Dallas, TX, USA"), + ).toBe(false); + }); + + it("arrowDownCountForPacIndex:Google Places 默认高亮首项,index 0 无需 ArrowDown", () => { + expect(arrowDownCountForPacIndex(0)).toBe(0); + expect(arrowDownCountForPacIndex(1)).toBe(1); + expect(arrowDownCountForPacIndex(2)).toBe(2); + }); + + it("formatAddressQuery 不含邮编(仅街道+城市+州+国家)", () => { + expect(formatAddressQuery(laQuery)).toBe( + "1234 Warehouse Blvd, Los Angeles, CA, USA", + ); + }); + + it("formatAddressQuery 无 zip 字段时仍含国家", () => { + expect( + formatAddressQuery({ street: "1 Main", city: "Austin", state: "TX", zip: "" }), + ).toBe("1 Main, Austin, TX, USA"); + }); + + it("formatMothershipFormattedAddress 不含邮编", () => { + expect( + formatMothershipFormattedAddress( + "1234 Warehouse Street", + "Los Angeles", + "CA", + ), + ).toBe("1234 Warehouse Street, Los Angeles, CA, USA"); + }); + + it("pipe 副行含 USA 时州码取 TX 而非 USA", () => { + const item = labelToCandidate( + "5678 Distribution Dr|Garland, Dallas, TX, USA", + { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ); + expect(item?.city).toBe("Garland"); + expect(item?.state).toBe("TX"); + expect(item?.display_label).toBe("5678 Distribution DrGarland, TX"); + }); + + it("无法解析时不回退用户输入街道", () => { + expect( + labelToCandidate("invalid short", { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }), + ).toBeNull(); + }); + + it("仅街道行联想可补全 city/state(Warehouse Street/Avenue)", () => { + const query = { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }; + const street = labelToCandidate("1234 Warehouse Street", query); + const avenue = labelToCandidate("1234 Warehouse Avenue", query); + expect(street?.street).toBe("1234 Warehouse Street"); + expect(avenue?.street).toBe("1234 Warehouse Avenue"); + expect(street?.city).toBe("Los Angeles"); + expect(isMotherShipEchoCandidate(street!, query)).toBe(false); + expect(street?.option_id).not.toBe(avenue?.option_id); + }); + + it("pipe 联想主行等于 streetToken 时仍应保留(Garland 等副行)", () => { + const query = { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }; + expect(isMotherShipEnumerationRowForTest("5678 Distribution Dr|Garland, Texas, USA", query)).toBe(true); + expect(isMotherShipEnumerationRowForTest("5678 Distribution Dr|Wilmer, TX, USA", query)).toBe(true); + expect(isMotherShipEnumerationRowForTest("5678 Distribution Dr|Dallas, TX, USA", query)).toBe(false); + }); + + it("不同街道名不算 echo(Warehouse Street vs Blvd)", () => { + const item = labelToCandidate( + "1234 Warehouse Street|Los Angeles, California, USA", + { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + ); + expect(item).not.toBeNull(); + if (item) { + expect( + isMotherShipEchoCandidate(item, { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }), + ).toBe(false); + } + }); + + it("isMotherShipEchoCandidate 识别用户输入回显", () => { + const echo = labelToCandidate( + "5678 Distribution Dr|Dallas, TX, USA", + { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ); + expect(echo).not.toBeNull(); + if (echo) { + expect( + isMotherShipEchoCandidate(echo, { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }), + ).toBe(true); + } + const real = labelToCandidate( + "5678 Distribution Dr|Garland, Dallas, TX, USA", + { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ); + expect(real).not.toBeNull(); + if (real) { + expect( + isMotherShipEchoCandidate(real, { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }), + ).toBe(false); + } + }); + + it("buildAddressEnumerationQueries 完整 query 优先(含 USA)", () => { + expect( + buildAddressEnumerationQueries({ + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + })[0], + ).toBe("5678 Distribution Dr, Dallas, TX, USA"); + }); + + it("enrichCandidateFromQuery 联想缺门牌时补全用户 street", () => { + const item = labelToCandidate("Distribution WayFarmers Branch, TX", { + street: "5678 Distribution Way", + city: "Farmers Branch", + state: "TX", + zip: "", + }); + expect(item?.street).toBe("5678 Distribution Way"); + expect(item?.display_label).toContain("5678"); + expect(item?.formatted_address).toContain("5678 Distribution Way"); + }); + + it("buildAddressBindingLabels 完整街道优先于粘连 label", () => { + const labels = buildAddressBindingLabels( + { + street: "5678 Distribution Way", + city: "Farmers Branch", + state: "TX", + zip: "", + }, + "Distribution WayFarmers Branch, TX", + ); + expect(labels[0]).toBe("5678 Distribution Way, Farmers Branch, TX"); + expect(labels).toContain("Distribution WayFarmers Branch, TX"); + }); + + it("pac-item 结构化标签保留 suburb 差异", () => { + const garland = labelToCandidate( + "5678 Distribution Dr|Garland, Dallas, TX, USA", + { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ); + const wilmer = labelToCandidate( + "5678 Distribution Dr|Wilmer, Dallas, TX, USA", + { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ); + expect(garland?.city).toBe("Garland"); + expect(wilmer?.city).toBe("Wilmer"); + expect(garland?.display_label).not.toBe(wilmer?.display_label); + }); + + it("粘连 innerText 解析 suburb(Farmers Branch)", () => { + const item = labelToCandidate( + "5678 Distribution Way Farmers Branch, Dallas, TX, USA", + { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ); + expect(item?.street).toBe("5678 Distribution Way"); + expect(item?.city).toBe("Farmers Branch"); + expect(item?.display_label).toContain("Farmers Branch"); + }); + + it("解析粘连格式(录制 112133)", () => { + const item = labelToCandidate( + "1234 Warehouse StreetLos Angeles, CA", + laQuery, + ); + expect(item?.street).toBe("1234 Warehouse Street"); + expect(item?.city).toBe("Los Angeles"); + expect(item?.state).toBe("CA"); + expect(item?.zip).toBe("90001"); + expect(item?.display_label).toBe("1234 Warehouse StreetLos Angeles, CA"); + expect(item?.formatted_address).toBe( + "1234 Warehouse Street, Los Angeles, CA, USA", + ); + }); + + it("候选 zip 从用户输入补全", () => { + const item = labelToCandidate("5678 Distribution DrEagle Pass, TX", { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }); + expect(item?.zip).toBe("75201"); + expect(item?.display_label).not.toMatch(/\d{5}/); + }); + + it("scoreSuggestionLabelMatch 对齐 USA/粘连差异", () => { + const userLabel = "1234 Warehouse Street Los Angeles, CA, USA"; + const domLabel = "1234 Warehouse StreetLos Angeles, CA"; + expect(scoreSuggestionLabelMatch(userLabel, domLabel, laQuery)).toBeGreaterThanOrEqual( + 90, + ); + }); + + it("scoreSuggestionLabelMatch 区分 West Seattle 与 Western Ave", () => { + const query = { + street: "3131 Western Avenue", + city: "West Seattle", + state: "WA", + zip: "", + }; + const userLabel = "3131 Western AvenueWest Seattle, WA"; + const wrongDom = "3131 Western Ave Seattle, WA, USA"; + const rightDom = "3131 Western Avenue West Seattle, WA, USA"; + expect(scoreSuggestionLabelMatch(userLabel, wrongDom, query)).toBeLessThan( + scoreSuggestionLabelMatch(userLabel, rightDom, query), + ); + }); + + it("解析无门牌号街道(Fifth Avenue / Hollywood Boulevard)", () => { + const fifth = labelToCandidate("Fifth Avenue New York, NY, USA", { + street: "Fifth Avenue", + city: "New York", + state: "NY", + zip: "", + }); + expect(fifth?.street).toBe("Fifth Avenue"); + expect(fifth?.city).toBe("New York"); + expect(fifth?.state).toBe("NY"); + + const hollywood = labelToCandidate( + "Hollywood Boulevard Los Angeles, CA, USA", + { + street: "Hollywood Boulevard", + city: "Los Angeles", + state: "CA", + zip: "", + }, + ); + expect(hollywood?.street).toBe("Hollywood Boulevard"); + expect(hollywood?.city).toBe("Los Angeles"); + }); + + it("normalizeParsedStreet 去除误并入街道的城市名", () => { + const item = labelToCandidate( + "5678 Distribution Dr Garland, Dallas, TX", + { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ); + expect(item?.street).toBe("5678 Distribution Dr"); + expect(item?.city).toBe("Garland"); + }); + + it("buildAddressAutocompleteQueries preferConfirmedLabel 时完整 POI 标签优先", () => { + const queries = buildAddressAutocompleteQueries( + { + street: "Washington State Convention Center Main Garage, Pike Street", + city: "Seattle", + state: "WA", + zip: "", + }, + { + mothershipLabel: + "Washington State Convention Center Main Garage, Pike Street, Seattle, Washington, USA", + preferConfirmedLabel: true, + }, + ); + expect(queries[0]).toBe( + "Washington State Convention Center Main Garage, Pike Street, Seattle, Washington", + ); + }); + + it("buildAddressAutocompleteQueries 从 mothershipLabel 提取纯街道查询(触发多候选联想)", () => { + const queries = buildAddressAutocompleteQueries( + { + street: "La Concha Key West, Autograph Collection Duval Street", + city: "Key West", + state: "FL", + zip: "", + }, + { mothershipLabel: "La Concha Key West, Autograph Collection, Duval Street, Key West, FL" }, + ); + expect(queries[0]).toBe("Duval Street, Key West, FL, USA"); + expect(queries[1]).toBe("Duval Street, Key West, FL"); + const poiIdx = queries.indexOf( + "La Concha Key West, Autograph Collection Duval Street, Key West, FL, USA", + ); + expect(poiIdx).toBeGreaterThan(1); + }); + + it("extractStreetFromLabel 处理 POI 与街道粘连(无逗号)", () => { + expect( + extractStreetFromLabel( + "La Concha Key West, Autograph Collection Duval Street, FL", + ), + ).toBe("Duval Street"); + }); + + it("resolveAddressAutocompleteInput 用纯街道替换 POI street", () => { + const resolved = resolveAddressAutocompleteInput( + { + street: "La Concha Key West, Autograph Collection Duval Street", + city: "Key West", + state: "FL", + zip: "", + }, + { + mothershipLabel: + "La Concha Key West, Autograph Collection Duval Street, FL", + }, + ); + expect(resolved.street).toBe("Duval Street"); + }); + + it("buildAddressAutocompleteQueries 无 mothershipLabel 时保留原有行为", () => { + const queries = buildAddressAutocompleteQueries({ + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }); + expect(queries[0]).toBe("5678 Distribution Dr, Dallas, TX, USA"); + }); + + it("buildAddressAutocompleteQueries 处理粘连格式 mothershipLabel", () => { + const queries = buildAddressAutocompleteQueries( + { + street: "3131 Western AvenueWest Seattle", + city: "West Seattle", + state: "WA", + zip: "", + }, + { mothershipLabel: "3131 Western AvenueWest Seattle, WA" }, + ); + expect(queries[0]).toBe("3131 Western Avenue, West Seattle, WA, USA"); + }); +}); diff --git a/__tests__/workers/rpa/axel-place-prefetch.test.ts b/__tests__/workers/rpa/axel-place-prefetch.test.ts new file mode 100644 index 0000000..73961d4 --- /dev/null +++ b/__tests__/workers/rpa/axel-place-prefetch.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Page } from "playwright"; +import { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge"; +import { commitViaAxelPlacePrefetch } from "@/workers/rpa/axel-place-prefetch"; + +describe("commitViaAxelPlacePrefetch", () => { + it("GET 200 但 fill 回退不合成 commit", async () => { + const bridge = new AxelSelectionBridge(); + const page = { + context: () => ({ + cookies: vi.fn().mockResolvedValue([]), + request: { + get: vi.fn().mockResolvedValue({ + status: () => 200, + ok: () => true, + json: async () => ({ + placeId: "ChIJ_test_place_id_00", + city: "LA", + }), + }), + }, + }), + evaluate: vi.fn().mockResolvedValue(false), + keyboard: { press: vi.fn().mockResolvedValue(undefined) }, + waitForTimeout: vi.fn().mockResolvedValue(undefined), + } as unknown as Page; + const input = { + click: vi.fn().mockResolvedValue(undefined), + fill: vi.fn().mockResolvedValue(undefined), + }; + + const result = await commitViaAxelPlacePrefetch( + page, + bridge, + "ChIJ_test_place_id_00", + "1234 Warehouse Street, Los Angeles, CA, USA", + input as never, + ); + + expect(result.ok).toBe(false); + expect(result.location).toBeTruthy(); + expect(bridge.hasPlaceCommit("ChIJ_test_place_id_00")).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/axel-selection-bridge.test.ts b/__tests__/workers/rpa/axel-selection-bridge.test.ts new file mode 100644 index 0000000..22d15e4 --- /dev/null +++ b/__tests__/workers/rpa/axel-selection-bridge.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { AxelSelectionBridge, formatPlaceCommitFailure, normalizeAxelPlaceId } from "@/workers/rpa/axel-selection-bridge"; + +describe("AxelSelectionBridge", () => { + it("resolveSelection 从 googlePlacesResults 绑定 placeId", () => { + const bridge = new AxelSelectionBridge(); + bridge.ingestSearchResults({ + googlePlacesResults: [ + { + description: "1234 Warehouse Street, Los Angeles, CA 90001, USA", + mainText: "1234 Warehouse Street", + placeId: "ChIJ_test_la", + secondaryText: "Los Angeles, CA 90001, USA", + }, + ], + }); + + const binding = bridge.resolveSelection( + "1234 Warehouse StreetLos Angeles, CA", + { + street: "1234 Warehouse Street", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + ); + + expect(binding).toEqual({ + label: "1234 Warehouse StreetLos Angeles, CA", + placeId: "ChIJ_test_la", + description: "1234 Warehouse Street, Los Angeles, CA 90001, USA", + }); + }); + + it("记录 place/{id} 200 为 commit", () => { + const bridge = new AxelSelectionBridge(); + bridge.recordPlaceCommit("ChIJ_commit_ok"); + + expect(bridge.hasPlaceCommit("ChIJ_commit_ok")).toBe(true); + expect(bridge.getObservedPlaceCommitIds()).toContain("ChIJ_commit_ok"); + }); + + it("normalizeAxelPlaceId 从 base64 复合串提取 ChIJ", () => { + const raw = + "EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol"; + expect(normalizeAxelPlaceId(raw)).toBe("ChIJywDT6CXGwoARuoz5HIJe20g"); + }); + + it("normalizeAxelPlaceId 截断 ChIJ 后粘连 base64 尾巴", () => { + const composite = + "ChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol"; + expect(normalizeAxelPlaceId(composite)).toBe("ChIJywDT6CXGwoARuoz5HIJe20g"); + }); + + it("resolveSelection 有门牌 query 时优先带门牌的 placeId", () => { + const bridge = new AxelSelectionBridge(); + bridge.ingestSearchResults({ + googlePlacesResults: [ + { + description: "Distribution Way, Farmers Branch, TX, USA", + mainText: "Distribution Way", + placeId: "ChIJ_generic_way", + secondaryText: "Farmers Branch, TX, USA", + }, + { + description: "5678 Distribution Way, Farmers Branch, TX, USA", + mainText: "5678 Distribution Way", + placeId: "ChIJ_numbered_way", + secondaryText: "Farmers Branch, TX, USA", + }, + ], + }); + + const binding = bridge.resolveSelection("Distribution WayFarmers Branch, TX", { + street: "5678 Distribution Way", + city: "Farmers Branch", + state: "TX", + zip: "", + }); + + expect(binding?.placeId).toBe("ChIJ_numbered_way"); + }); + + it("无 search 结果时 resolveSelection 返回 null", () => { + const bridge = new AxelSelectionBridge(); + expect( + bridge.resolveSelection("1234 Warehouse StreetLos Angeles, CA"), + ).toBeNull(); + }); + + it("formatPlaceCommitFailure 区分无请求与 HTTP 错误", () => { + expect( + formatPlaceCommitFailure("ChIJ_expected", ["keyboard-bound-0"], { + committedPlaceIds: [], + placeNetworkLog: [], + }), + ).toContain("未发起 GET place 请求"); + + expect( + formatPlaceCommitFailure("ChIJ_expected", ["pac-item-0"], { + committedPlaceIds: [], + placeNetworkLog: [{ placeId: "ChIJ_other", status: 404, url: "", at: 0 }], + }), + ).toContain("placeId 不匹配"); + }); +}); diff --git a/__tests__/workers/rpa/browser-place-commit.test.ts b/__tests__/workers/rpa/browser-place-commit.test.ts new file mode 100644 index 0000000..1aab252 --- /dev/null +++ b/__tests__/workers/rpa/browser-place-commit.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { BROWSER_FETCH_AND_INJECT_PLACE } from "@/workers/rpa/fix-place/browser-place-commit"; + +describe("browser-place-commit", () => { + it("BROWSER_FETCH_AND_INJECT_PLACE 为 Playwright 可执行 async 函数体", () => { + expect(BROWSER_FETCH_AND_INJECT_PLACE.trim().startsWith("async (arg)")).toBe( + true, + ); + expect(BROWSER_FETCH_AND_INJECT_PLACE).not.toContain("(async function"); + }); +}); diff --git a/__tests__/workers/rpa/cargo-commit.test.ts b/__tests__/workers/rpa/cargo-commit.test.ts new file mode 100644 index 0000000..058ebc6 --- /dev/null +++ b/__tests__/workers/rpa/cargo-commit.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateAddressesLockedForSubmit, + evaluateCargoCommitted, + type CargoCommitProbe, +} from "@/workers/rpa/cargo-lock"; + +describe("cargo commit barrier", () => { + const committed: CargoCommitProbe = { + hasSummaryChip: true, + visibleEditableCargoInputs: 0, + editorOpen: false, + internalPalletCount: 2, + internalWeight: 227, + widgetTextPreview: "2 托盘s", + }; + + it("summary + 无 editable input + internal pallet → committed", () => { + expect(evaluateCargoCommitted(committed, 2)).toBe(true); + }); + + it("内联步输入仍可见但摘要完整 → committed", () => { + expect( + evaluateCargoCommitted( + { + hasSummaryChip: true, + visibleEditableCargoInputs: 5, + editorOpen: true, + internalPalletCount: 2, + internalWeight: 226, + widgetTextPreview: + "2 托盘s 40W, x 48L, x 48H, x 226Lbs 货运详情", + }, + 2, + ), + ).toBe(true); + }); + + it("摘要不完整且仍有 editable → 未 committed", () => { + expect( + evaluateCargoCommitted( + { + hasSummaryChip: false, + visibleEditableCargoInputs: 2, + editorOpen: true, + internalPalletCount: null, + internalWeight: null, + widgetTextPreview: "货运详情", + }, + 2, + ), + ).toBe(false); + }); + + it("摘要含托盘+尺寸但无重量 → 未 committed", () => { + expect( + evaluateCargoCommitted( + { + hasSummaryChip: true, + visibleEditableCargoInputs: 0, + editorOpen: true, + internalPalletCount: 2, + internalWeight: null, + widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x -Lbs", + }, + 2, + ), + ).toBe(false); + }); + + it("摘要含 226Lbs 无空格 → committed", () => { + expect( + evaluateCargoCommitted( + { + hasSummaryChip: true, + visibleEditableCargoInputs: 0, + editorOpen: false, + internalPalletCount: 2, + internalWeight: 226, + widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 226Lbs", + }, + 2, + ), + ).toBe(true); + }); + + it("editor 打开且仍有 editable → 未 committed", () => { + expect( + evaluateCargoCommitted( + { ...committed, editorOpen: true, visibleEditableCargoInputs: 2 }, + 2, + ), + ).toBe(false); + }); + + it("摘要重量与请求单托不符 → 未 committed", () => { + expect( + evaluateCargoCommitted( + { + hasSummaryChip: true, + visibleEditableCargoInputs: 0, + editorOpen: false, + internalPalletCount: 2, + internalWeight: 2296, + widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 2296Lbs", + }, + { + palletCount: 2, + weight: { + displayValue: "500", + unit: "lb", + weightLbPerPallet: 500, + palletCount: 2, + }, + }, + ), + ).toBe(false); + }); +}); + +describe("address lock barrier", () => { + it("双州码 → locked", () => { + expect( + evaluateAddressesLockedForSubmit( + "1234 Warehouse St, Los Angeles, CA 90021 → 5678 Distribution Way, Dallas, TX 75234", + false, + ), + ).toBe(true); + }); + + it("单州码 → 未 locked", () => { + expect( + evaluateAddressesLockedForSubmit("Los Angeles, CA 90021 货运详情", false), + ).toBe(false); + }); + + it("地址搜索仍可编辑 → 未 locked", () => { + expect( + evaluateAddressesLockedForSubmit( + "1234 Warehouse St, CA 90021 5678 Distribution Way, TX 75234", + true, + ), + ).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/cargo-editor.test.ts b/__tests__/workers/rpa/cargo-editor.test.ts new file mode 100644 index 0000000..b17ce1b --- /dev/null +++ b/__tests__/workers/rpa/cargo-editor.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Page } from "playwright"; +import { isCargoEditorOpen } from "@/workers/rpa/cargo-editor"; + +function mockPage(visible: (selector: string) => boolean): Page { + const getByRole = vi.fn((_role: string, opts?: { name?: RegExp }) => ({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue( + opts?.name?.test("ADD ITEM") ?? false, + ), + }), + })); + const getByPlaceholder = vi.fn((pattern: RegExp) => ({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(/weight/i.test(pattern.source)), + }), + })); + return { + locator: vi.fn((selector: string) => ({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(visible(selector)), + }), + last: () => ({ + isVisible: vi.fn().mockResolvedValue(visible(selector)), + }), + })), + getByRole, + getByPlaceholder, + getByText: vi.fn(() => ({ + first: () => ({ isVisible: vi.fn().mockResolvedValue(false) }), + })), + } as unknown as Page; +} + +describe("isCargoEditorOpen", () => { + it("role=dialog 可见时视为已打开", async () => { + const page = mockPage((s) => s.includes("dialog")); + expect(await isCargoEditorOpen(page)).toBe(true); + }); + + it("popover:Weight placeholder 可见时视为已打开(无 dialog)", async () => { + const page = mockPage(() => false); + expect(await isCargoEditorOpen(page)).toBe(true); + }); + + it("无 dialog 且无 editor 信号时未打开", async () => { + const page = { + locator: vi.fn(() => ({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(false), + }), + })), + getByRole: vi.fn(() => ({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(false), + }), + })), + getByPlaceholder: vi.fn(() => ({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(false), + }), + })), + getByText: vi.fn(() => ({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(false), + }), + })), + } as unknown as Page; + + expect(await isCargoEditorOpen(page)).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/cargo-reset.test.ts b/__tests__/workers/rpa/cargo-reset.test.ts new file mode 100644 index 0000000..4ee50ea --- /dev/null +++ b/__tests__/workers/rpa/cargo-reset.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { widgetHasFreightSummary } from "@/workers/rpa/cargo-reset"; + +describe("widgetHasFreightSummary", () => { + it("含托盘+尺寸摘要 → true", () => { + expect( + widgetHasFreightSummary( + "2 托盘s 40W, x 48L, x 44H, x 2079Lbs", + ), + ).toBe(true); + }); + + it("仅地址无货物 → false", () => { + expect( + widgetHasFreightSummary("Denver, CO Charlotte, NC 4800 South Race Street"), + ).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/cargo-state.test.ts b/__tests__/workers/rpa/cargo-state.test.ts new file mode 100644 index 0000000..1e5352d --- /dev/null +++ b/__tests__/workers/rpa/cargo-state.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + isCargoStateReady, + type CargoStateEvidence, +} from "@/workers/rpa/cargo-state"; + +describe("isCargoStateReady", () => { + it("hydrating:mounted 但无 interactive → not ready", () => { + const evidence: CargoStateEvidence = { + phase: "hydrating", + mountedCargoFields: [{ placeholder: "需要几个托盘?", ariaLabel: null }], + interactiveCargoFields: [], + blockedCargoFields: [ + { + placeholder: "需要几个托盘?", + ariaLabel: null, + blockers: ["pointer_events_none"], + }, + ], + }; + expect(isCargoStateReady(evidence)).toBe(false); + }); + + it("仅 interactive 非托盘字段 → not ready", () => { + const evidence: CargoStateEvidence = { + phase: "interactive", + mountedCargoFields: [{ placeholder: "长度", ariaLabel: null }], + interactiveCargoFields: [{ placeholder: "长度", ariaLabel: null }], + blockedCargoFields: [], + }; + expect(isCargoStateReady(evidence)).toBe(false); + }); + + it("托盘 interactive → ready", () => { + const evidence: CargoStateEvidence = { + phase: "interactive", + mountedCargoFields: [{ placeholder: "需要几个托盘?", ariaLabel: null }], + interactiveCargoFields: [ + { placeholder: "需要几个托盘?", ariaLabel: null }, + ], + blockedCargoFields: [], + }; + expect(isCargoStateReady(evidence)).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/cargo-weight.test.ts b/__tests__/workers/rpa/cargo-weight.test.ts new file mode 100644 index 0000000..b3002a2 --- /dev/null +++ b/__tests__/workers/rpa/cargo-weight.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import type { CargoCommitProbe } from "@/workers/rpa/cargo-lock"; +import { + buildWeightDisplayForUnit, + inferWeightUnitFromFieldHint, + isStaleCargoState, + probeWeightMatchesExpected, + resolveMotherShipWeightDisplay, +} from "@/workers/rpa/cargo-weight"; + +const expected500x2 = { + ...resolveMotherShipWeightDisplay(500, "Weight (lbs)"), + palletCount: 2, +}; + +function probe( + overrides: Partial, +): CargoCommitProbe { + return { + hasSummaryChip: true, + visibleEditableCargoInputs: 0, + editorOpen: false, + internalPalletCount: 2, + internalWeight: 500, + widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 500Lbs", + ...overrides, + }; +} + +describe("inferWeightUnitFromFieldHint", () => { + it("lbs placeholder → lb", () => { + expect(inferWeightUnitFromFieldHint("Weight (lbs)")).toBe("lb"); + }); + + it("kg placeholder → kg", () => { + expect(inferWeightUnitFromFieldHint("Weight (kg)")).toBe("kg"); + }); + + it("合并多 spec 字符串含 kg 时仍优先 lb(有 lbs 标记)", () => { + const joined = "Weight (lbs) Weight (kg) 重量(公斤)"; + expect(inferWeightUnitFromFieldHint(joined)).toBe("lb"); + }); +}); + +describe("buildWeightDisplayForUnit", () => { + it("lb 页直接填原始 lb,不换算", () => { + expect(buildWeightDisplayForUnit(503, "lb")).toEqual({ + displayValue: "503", + unit: "lb", + weightLbPerPallet: 503, + }); + }); + + it("kg 页才换算", () => { + const r = buildWeightDisplayForUnit(500, "kg"); + expect(r.unit).toBe("kg"); + expect(Number(r.displayValue)).toBeCloseTo(226.8, 0); + }); +}); + +describe("resolveMotherShipWeightDisplay", () => { + it("lbs 字段填单托 lb", () => { + expect(resolveMotherShipWeightDisplay(500, "Weight (lbs)")).toEqual({ + displayValue: "500", + unit: "lb", + weightLbPerPallet: 500, + }); + }); + + it("kg 字段填单托 kg", () => { + const r = resolveMotherShipWeightDisplay(500, "Weight (kg)"); + expect(r.unit).toBe("kg"); + expect(Number(r.displayValue)).toBeCloseTo(226.8, 0); + }); + + it("503lb 在 lbs 页不得变成 228.16", () => { + expect(resolveMotherShipWeightDisplay(503, "Weight (lbs)").displayValue).toBe( + "503", + ); + }); +}); + +describe("probeWeightMatchesExpected", () => { + it("单托 500lb × 2 托 → 匹配", () => { + expect(probeWeightMatchesExpected(probe({}), expected500x2)).toBe(true); + }); + + it("widget 残留 2296lb → 不匹配", () => { + expect( + probeWeightMatchesExpected( + probe({ + internalWeight: 2296, + widgetTextPreview: "2 托盘s x 2296Lbs", + }), + expected500x2, + ), + ).toBe(false); + }); + + it("托盘数不一致 → 不匹配", () => { + expect( + probeWeightMatchesExpected( + probe({ internalPalletCount: 4 }), + expected500x2, + ), + ).toBe(false); + }); + + it("摘要总重匹配 → 通过", () => { + expect( + probeWeightMatchesExpected( + { + hasSummaryChip: true, + visibleEditableCargoInputs: 0, + editorOpen: false, + internalPalletCount: 3, + internalWeight: null, + widgetTextPreview: "3 托盘s x 1371Lbs", + }, + { + displayValue: "457", + unit: "lb", + weightLbPerPallet: 457, + palletCount: 3, + }, + ), + ).toBe(true); + }); +}); + +describe("isStaleCargoState", () => { + it("无摘要 → 非残留", () => { + expect( + isStaleCargoState( + probe({ hasSummaryChip: false, internalWeight: null }), + expected500x2, + ), + ).toBe(false); + }); + + it("摘要重量错误 → 残留", () => { + expect( + isStaleCargoState( + probe({ internalWeight: 2296, widgetTextPreview: "x 2296Lbs" }), + expected500x2, + ), + ).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/dual-address-patch.test.ts b/__tests__/workers/rpa/dual-address-patch.test.ts new file mode 100644 index 0000000..36cfb99 --- /dev/null +++ b/__tests__/workers/rpa/dual-address-patch.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { DUAL_ADDRESS_PATCH_INIT } from "@/workers/rpa/inject/dual-address-patch-init"; +import { isDualAddressPatchEnabled } from "@/lib/rpa/env"; + +describe("dual-address-patch", () => { + it("init script 包含 vault API 标识", () => { + expect(DUAL_ADDRESS_PATCH_INIT).toContain("__rpaDualAddressPatchInstalled"); + expect(DUAL_ADDRESS_PATCH_INIT).toContain("__rpaDualAddressApi"); + expect(DUAL_ADDRESS_PATCH_INIT).toContain("preservePickup"); + }); + + it("显式 RPA_DUAL_ADDRESS_PATCH=true 时启用 patch", () => { + const prev = process.env.RPA_DUAL_ADDRESS_PATCH; + const prevQuote = process.env.RPA_QUOTE_MODE; + process.env.RPA_DUAL_ADDRESS_PATCH = "true"; + process.env.RPA_QUOTE_MODE = "widget"; + expect(isDualAddressPatchEnabled()).toBe(true); + process.env.RPA_DUAL_ADDRESS_PATCH = "false"; + expect(isDualAddressPatchEnabled()).toBe(false); + process.env.RPA_DUAL_ADDRESS_PATCH = prev ?? ""; + process.env.RPA_QUOTE_MODE = prevQuote ?? ""; + }); +}); diff --git a/__tests__/workers/rpa/fill-cargo-in-drawer.test.ts b/__tests__/workers/rpa/fill-cargo-in-drawer.test.ts new file mode 100644 index 0000000..909d84e --- /dev/null +++ b/__tests__/workers/rpa/fill-cargo-in-drawer.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + buildWeightDisplayForUnit, + inferWeightUnitFromFieldHint, +} from "@/workers/rpa/cargo-weight"; +import { classifyCargoFieldHint } from "@/workers/rpa/fill-cargo-in-drawer"; + +describe("classifyCargoFieldHint", () => { + it("识别托数/重量/尺寸", () => { + expect(classifyCargoFieldHint("How many pallets?")).toBe("pallet"); + expect(classifyCargoFieldHint("Weight (lbs)")).toBe("weight"); + expect(classifyCargoFieldHint("Weight (kg)")).toBe("weight"); + expect(classifyCargoFieldHint("重量(公斤)")).toBe("weight"); + expect(classifyCargoFieldHint("Length")).toBe("length"); + expect(classifyCargoFieldHint("Width")).toBe("width"); + expect(classifyCargoFieldHint("Height")).toBe("height"); + expect(classifyCargoFieldHint("需要几个托盘?")).toBe("pallet"); + }); + + it("kg placeholder 仅换算 kg 字段", () => { + const display = buildWeightDisplayForUnit( + 503, + inferWeightUnitFromFieldHint("Weight (kg)"), + ); + expect(display.unit).toBe("kg"); + expect(display.displayValue).not.toBe("503"); + const lbs = buildWeightDisplayForUnit( + 503, + inferWeightUnitFromFieldHint("Weight (lbs)"), + ); + expect(lbs.displayValue).toBe("503"); + }); +}); diff --git a/__tests__/workers/rpa/fix-place/browser-eval-scripts.test.ts b/__tests__/workers/rpa/fix-place/browser-eval-scripts.test.ts new file mode 100644 index 0000000..38cfa6a --- /dev/null +++ b/__tests__/workers/rpa/fix-place/browser-eval-scripts.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + CHECK_PICKUP_IN_REACT_STATE, + INJECT_REACT_STATE, + INVOKE_CAPTURED_PLACE_CHANGED, + TRIGGER_GOOGLE_PLACE_CHANGED, +} from "@/workers/rpa/fix-place/browser-eval-scripts"; + +describe("fix-place/browser-eval-scripts", () => { + it("脚本为纯字符串,不含 esbuild helper 引用", () => { + const scripts = [ + TRIGGER_GOOGLE_PLACE_CHANGED, + INVOKE_CAPTURED_PLACE_CHANGED, + CHECK_PICKUP_IN_REACT_STATE, + INJECT_REACT_STATE, + ]; + for (const script of scripts) { + expect(script).not.toMatch(/__name|__assign|__awaiter|__spread/); + expect(typeof script).toBe("string"); + expect(script.length).toBeGreaterThan(10); + } + }); +}); diff --git a/__tests__/workers/rpa/fix-place/cdp-click.test.ts b/__tests__/workers/rpa/fix-place/cdp-click.test.ts new file mode 100644 index 0000000..b27ac0a --- /dev/null +++ b/__tests__/workers/rpa/fix-place/cdp-click.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { RESOLVE_CLICK_TARGET } from "@/workers/rpa/fix-place/cdp-click"; + +describe("cdp-click", () => { + it("RESOLVE_CLICK_TARGET 为纯字符串脚本", () => { + expect(typeof RESOLVE_CLICK_TARGET).toBe("string"); + expect(RESOLVE_CLICK_TARGET).toContain("getBoundingClientRect"); + expect(RESOLVE_CLICK_TARGET).not.toMatch(/__name|__assign/); + }); +}); diff --git a/__tests__/workers/rpa/fix-place/place-diagnostic.test.ts b/__tests__/workers/rpa/fix-place/place-diagnostic.test.ts new file mode 100644 index 0000000..6bbb33a --- /dev/null +++ b/__tests__/workers/rpa/fix-place/place-diagnostic.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + PLACE_DIAG_COLLECT_SNAPSHOT, + PLACE_DIAG_SET_MARK, + P0_PLACE_DIAG_INIT, + P2_FETCH_HIJACK_INIT, +} from "@/workers/rpa/fix-place/place-diagnostic-init"; +import { interpretPlaceDiagnostic } from "@/workers/rpa/fix-place/place-diagnostic-probe"; + +describe("place-diagnostic-init", () => { + it("P0/P2 脚本为纯字符串", () => { + for (const script of [ + P0_PLACE_DIAG_INIT, + P2_FETCH_HIJACK_INIT, + PLACE_DIAG_SET_MARK, + PLACE_DIAG_COLLECT_SNAPSHOT, + ]) { + expect(typeof script).toBe("string"); + expect(script).not.toMatch(/__name|__assign/); + } + expect(P0_PLACE_DIAG_INIT).toContain("maps.event.addListener"); + expect(P0_PLACE_DIAG_INIT).toContain("fingerprintHandler"); + expect(P2_FETCH_HIJACK_INIT).toContain("axel/location/place"); + }); +}); + +describe("interpretPlaceDiagnostic", () => { + it("无监听器时提示 input gm 定位", () => { + const lines = interpretPlaceDiagnostic( + { + ok: true, + topology: { instanceCount: 0, instances: [], inputProbes: [], monkeyPatchInstances: 0, capturedHandlers: 0 }, + listeners: { total: 0, anyHasFetch: false, anyHasAxelPlace: false, rows: [] }, + handlerEvents: [], + fetchLog: [], + }, + "test", + ); + expect(lines.some((l) => l.includes("无任何 place_changed 监听器"))).toBe(true); + }); + + it("有 handler 无 fetch 时提示 P2", () => { + const lines = interpretPlaceDiagnostic( + { + ok: true, + topology: { instanceCount: 1, instances: [], inputProbes: [], monkeyPatchInstances: 1, capturedHandlers: 1 }, + listeners: { total: 1, anyHasFetch: false, anyHasAxelPlace: false, rows: [] }, + handlerEvents: [{ type: "place_changed_handler", instanceId: "ac-1", getPlaceBefore: { place_id: "ChIJ_test", hasGeometry: true } }], + fetchLog: [], + }, + "test", + ); + expect(lines.some((l) => l.includes("P2 无 place fetch"))).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/gold-standard-targets.test.ts b/__tests__/workers/rpa/gold-standard-targets.test.ts new file mode 100644 index 0000000..02adaae --- /dev/null +++ b/__tests__/workers/rpa/gold-standard-targets.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + findGoldStandardTargets, + loadAllGoldStandardTargets, + scoreGoldTextMatch, +} from "@/workers/rpa/gold-standard-targets"; + +describe("gold-standard-targets", () => { + it("loadAllGoldStandardTargets 读取录证金样本", () => { + const targets = loadAllGoldStandardTargets(); + expect(targets.length).toBeGreaterThan(0); + expect(targets[0]?.textPreview.length).toBeGreaterThan(12); + }); + + it("scoreGoldTextMatch 对完全匹配给高分", () => { + const text = + "La Concha Key West, Autograph Collection, Duval Street, Key West, Florida, USA"; + const score = scoreGoldTextMatch(text, { + street: "Duval Street", + city: "Key West", + state: "FL", + zip: "", + }, text); + expect(score).toBeGreaterThanOrEqual(85); + }); + + it("scoreGoldTextMatch 拒绝仅城市相同的不同街道", () => { + const score = scoreGoldTextMatch( + "1234 Warehouse Street, Los Angeles, CA, USA", + { + street: "1234 Warehouse Street", + city: "Los Angeles", + state: "CA", + zip: "", + }, + "Hollywood BoulevardLos Angeles, California, USA", + ); + expect(score).toBe(0); + }); + + it("findGoldStandardTargets 按 description 匹配 Key West 金样本", () => { + const matches = findGoldStandardTargets( + "La Concha Key West, Autograph Collection, Duval Street, Key West, Florida, USA", + { + street: "Duval Street", + city: "Key West", + state: "FL", + zip: "", + }, + "pickup", + 40, + ); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0]?.textPreview).toMatch(/Key West|Duval/i); + }); +}); diff --git a/__tests__/workers/rpa/kernel-browser-scripts-exports.test.ts b/__tests__/workers/rpa/kernel-browser-scripts-exports.test.ts new file mode 100644 index 0000000..ed81a69 --- /dev/null +++ b/__tests__/workers/rpa/kernel-browser-scripts-exports.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + COLLECT_CLIENT_STATE, + FILL_CARGO_FIELD, + PROBE_CARGO_UI, + WAIT_CARGO_INTERACTIVE, +} from "@/workers/rpa/kernel/browser-scripts"; + +describe("kernel/browser-scripts re-exports", () => { + it("脚本为纯字符串,不含 TS helper 引用", () => { + const scripts = [ + PROBE_CARGO_UI, + WAIT_CARGO_INTERACTIVE, + FILL_CARGO_FIELD, + COLLECT_CLIENT_STATE, + ]; + for (const script of scripts) { + expect(script).not.toMatch(/__name|__assign|__awaiter|__spread/); + expect(typeof script).toBe("string"); + expect(script.length).toBeGreaterThan(10); + } + }); +}); diff --git a/__tests__/workers/rpa/kernel/cargo-step.test.ts b/__tests__/workers/rpa/kernel/cargo-step.test.ts new file mode 100644 index 0000000..b197013 --- /dev/null +++ b/__tests__/workers/rpa/kernel/cargo-step.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Page } from "playwright"; +import { fillCargoStep } from "@/workers/rpa/kernel/steps/cargo-step"; + +vi.mock("@/workers/rpa/fill-cargo-in-drawer", () => ({ + fillCargoInDrawer: vi.fn().mockResolvedValue({ + weightDisplay: { + displayValue: "500", + unit: "lb", + weightLbPerPallet: 500, + }, + expected: { + palletCount: 2, + weight: { + displayValue: "500", + unit: "lb", + weightLbPerPallet: 500, + palletCount: 2, + }, + }, + }), +})); + +vi.mock("@/workers/rpa/cargo-lock", () => ({ + assertBothAddressesLocked: vi.fn().mockResolvedValue(undefined), + settleAddressFieldsForCargo: vi.fn().mockResolvedValue(undefined), + waitForCargoCommittedState: vi.fn().mockResolvedValue({ + hasSummaryChip: true, + visibleEditableCargoInputs: 0, + editorOpen: false, + internalPalletCount: 2, + internalWeight: 500, + widgetTextPreview: "", + }), +})); + +const stateCalls: string[] = []; + +function mockCtx() { + const page = { + waitForTimeout: vi.fn().mockResolvedValue(undefined), + locator: vi.fn().mockReturnValue({ + last: vi.fn().mockReturnValue({ + locator: vi.fn().mockReturnValue({ first: vi.fn() }), + }), + filter: vi.fn().mockReturnValue({ nth: vi.fn() }), + first: vi.fn(), + }), + url: vi.fn().mockReturnValue("https://example.com"), + } as unknown as Page; + + return { + page, + selectors: { + specs: vi.fn().mockReturnValue(["input"]), + optionalEnv: vi.fn().mockReturnValue(undefined), + resolve: vi.fn().mockReturnValue("#widget"), + widget: vi.fn(), + }, + exec: { + evaluateOnLocator: vi.fn().mockResolvedValue(false), + }, + runtime: { + evaluateOnLocator: vi.fn(), + }, + getWidgetScrollAt: vi.fn().mockReturnValue(0), + setWidgetScrollAt: vi.fn(), + state: { + triggerCargoSection: vi.fn().mockResolvedValue(undefined), + settleAddressEditing: vi.fn(async () => { + stateCalls.push("settle"); + }), + }, + }; +} + +describe("fillCargoStep UI flow", () => { + it("顺序:fillCargoInDrawer → waitForCargoCommittedState", async () => { + const { fillCargoInDrawer } = await import("@/workers/rpa/fill-cargo-in-drawer"); + const { waitForCargoCommittedState } = await import("@/workers/rpa/cargo-lock"); + + const ctx = mockCtx() as never; + await fillCargoStep(ctx, { + cargoHash: "t", + pickup: {} as never, + delivery: {} as never, + weightLb: 500, + dimsIn: { l: 48, w: 40, h: 48 }, + palletCount: 2, + cargoType: "general_freight", + }); + + expect(fillCargoInDrawer).toHaveBeenCalled(); + expect(waitForCargoCommittedState).toHaveBeenCalled(); + }); + + it("fillCargoInDrawer 失败时向上抛出", async () => { + const { fillCargoInDrawer } = await import("@/workers/rpa/fill-cargo-in-drawer"); + const { RpaError } = await import("@/modules/rpa/errors"); + vi.mocked(fillCargoInDrawer).mockRejectedValueOnce( + new RpaError("STRUCT_CHANGE", "货物 drawer 内未找到任何重量输入框", { + retryable: true, + }), + ); + const ctx = mockCtx() as never; + await expect( + fillCargoStep(ctx, { + cargoHash: "t", + pickup: {} as never, + delivery: {} as never, + weightLb: 500, + dimsIn: { l: 48, w: 40, h: 48 }, + palletCount: 2, + cargoType: "general_freight", + }), + ).rejects.toMatchObject({ code: "STRUCT_CHANGE" }); + }); +}); diff --git a/__tests__/workers/rpa/kernel/context-validator.test.ts b/__tests__/workers/rpa/kernel/context-validator.test.ts new file mode 100644 index 0000000..8e1fff6 --- /dev/null +++ b/__tests__/workers/rpa/kernel/context-validator.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { RpaError } from "@/modules/rpa/errors"; +import { RPAContext, resolveRPAContext } from "@/workers/rpa/kernel/context"; +import { + assertRPAContext, + ensureArray, + isRPAContext, + snapshotContextContract, +} from "@/workers/rpa/kernel/context-validator"; + +function mockPage(): object { + return { + locator: () => ({ + filter: () => ({ nth: () => ({}) }), + first: () => ({}), + }), + waitForTimeout: async () => undefined, + url: () => "https://example.com", + }; +} + +describe("context-validator", () => { + it("ensureArray:undefined → []", () => { + expect(ensureArray(undefined)).toEqual([]); + expect(ensureArray(null)).toEqual([]); + expect(ensureArray([1])).toEqual([1]); + }); + + it("resolveRPAContext:已是 context 时不再包一层", () => { + const page = mockPage(); + const ctx = new RPAContext(page as never); + const resolved = resolveRPAContext(ctx); + expect(resolved).toBe(ctx); + expect(isRPAContext(resolved)).toBe(true); + }); + + it("fromSession 误传 context 不再导致 page.locator 断裂", () => { + const page = mockPage(); + const ctx = new RPAContext(page as never); + const wrapped = RPAContext.fromSession(ctx); + expect(wrapped).toBe(ctx); + expect(snapshotContextContract(wrapped).hasPage).toBe(true); + }); + + it("assertRPAContext:缺 state 时 fail-fast", () => { + const page = mockPage(); + const broken = { + page, + selectors: {}, + exec: {}, + runtime: {}, + getWidgetScrollAt: () => 0, + setWidgetScrollAt: () => undefined, + }; + expect(() => assertRPAContext(broken)).toThrow(RpaError); + }); +}); diff --git a/__tests__/workers/rpa/kernel/context.test.ts b/__tests__/workers/rpa/kernel/context.test.ts new file mode 100644 index 0000000..7e03870 --- /dev/null +++ b/__tests__/workers/rpa/kernel/context.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { SelectorGraph } from "@/workers/rpa/kernel/selector-graph"; +import { RPAContext } from "@/workers/rpa/kernel/context"; +import { isCargoStateReady } from "@/workers/rpa/kernel/state-engine"; + +function mockPage(): object { + return { + locator: () => ({ + filter: () => ({ nth: () => ({}) }), + first: () => ({}), + }), + waitForTimeout: async () => undefined, + url: () => "https://example.com", + }; +} + +describe("SelectorGraph", () => { + it("resolve widget selector", () => { + const graph = new SelectorGraph(); + expect(graph.resolve("RPA_SELECTOR_QUOTE_WIDGET")).toBeTruthy(); + }); + + it("specs 永不为 undefined", () => { + const graph = new SelectorGraph(); + expect(graph.specs("NOT_A_REAL_KEY")).toEqual([]); + }); +}); + +describe("RPAContext", () => { + it("scroll 时间戳存于实例", () => { + const ctx = new RPAContext(mockPage() as never); + expect(ctx.getWidgetScrollAt()).toBe(0); + ctx.setWidgetScrollAt(123); + expect(ctx.getWidgetScrollAt()).toBe(123); + }); + + it("runtime 与 exec 同源", () => { + const ctx = new RPAContext(mockPage() as never); + expect(ctx.runtime).toBe(ctx.exec); + }); +}); + +describe("isCargoStateReady", () => { + it("托盘 interactive → ready", () => { + expect( + isCargoStateReady({ + phase: "interactive", + mountedCargoFields: [{ placeholder: "需要几个托盘?", ariaLabel: null }], + interactiveCargoFields: [ + { placeholder: "需要几个托盘?", ariaLabel: null }, + ], + blockedCargoFields: [], + }), + ).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/kernel/normalize.test.ts b/__tests__/workers/rpa/kernel/normalize.test.ts new file mode 100644 index 0000000..be5fff3 --- /dev/null +++ b/__tests__/workers/rpa/kernel/normalize.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { normalizeCargoEvidence } from "@/workers/rpa/kernel/normalize"; + +describe("normalizeCargoEvidence", () => { + it("undefined → 空 evidence", () => { + expect(normalizeCargoEvidence(undefined).phase).toBe("no_cargo_inputs"); + expect(normalizeCargoEvidence(undefined).mountedCargoFields).toEqual([]); + }); + + it("数组字段 undefined → []", () => { + const evidence = normalizeCargoEvidence({ + phase: "hydrating", + mountedCargoFields: undefined as never, + interactiveCargoFields: undefined as never, + blockedCargoFields: undefined as never, + }); + expect(evidence.mountedCargoFields).toEqual([]); + }); +}); diff --git a/__tests__/workers/rpa/kernel/state-engine.test.ts b/__tests__/workers/rpa/kernel/state-engine.test.ts new file mode 100644 index 0000000..97aa647 --- /dev/null +++ b/__tests__/workers/rpa/kernel/state-engine.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isCargoStateReady } from "@/workers/rpa/kernel/state-engine"; + +describe("isCargoStateReady", () => { + it("首页精简 widget:weight 字段可视为就绪", () => { + expect( + isCargoStateReady({ + phase: "interactive", + mountedCargoFields: [], + interactiveCargoFields: [ + { placeholder: "Weight (lbs)", ariaLabel: null }, + ], + blockedCargoFields: [], + }), + ).toBe(true); + }); + + it("无 pallet/weight 字段时未就绪", () => { + expect( + isCargoStateReady({ + phase: "no_cargo_inputs", + mountedCargoFields: [], + interactiveCargoFields: [], + blockedCargoFields: [], + }), + ).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/mothership-styled-locator.test.ts b/__tests__/workers/rpa/mothership-styled-locator.test.ts new file mode 100644 index 0000000..99b3e88 --- /dev/null +++ b/__tests__/workers/rpa/mothership-styled-locator.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + buildDescriptionProbes, + labelsMatch, + suggestionLabelConflicts, +} from "@/workers/rpa/mothership-styled-suggestion-click"; + +describe("mothership-styled-locator", () => { + it("buildDescriptionProbes 生成多层文本探针", () => { + const probes = buildDescriptionProbes( + "Hollywood Boulevard, Los Angeles, California, USA", + { + street: "Hollywood Boulevard", + city: "Los Angeles", + state: "CA", + zip: "", + }, + ); + expect(probes).toContain("Hollywood Boulevard, Los Angeles, CA"); + expect(probes.some((p) => p.includes("Hollywood"))).toBe(true); + }); + + it("labelsMatch 支持包含匹配与门牌号", () => { + expect( + labelsMatch( + "1234 Warehouse Street, Los Angeles, CA, USA", + "1234 Warehouse Street Los Angeles CA", + ), + ).toBe(true); + }); + + it("labelsMatch 拒绝 Bridge 误匹配", () => { + expect( + labelsMatch( + "1234 Market Street Bridge, Philadelphia, PA, USA", + "1234 Market Street, Philadelphia, PA, USA", + ), + ).toBe(false); + }); + + it("suggestionLabelConflicts 识别 Bridge 歧义", () => { + expect( + suggestionLabelConflicts( + "800 Hennepin Avenue Bridge, Minneapolis, MN, USA", + { street: "800 Hennepin Avenue", city: "Minneapolis", state: "MN", zip: "" }, + "800 Hennepin Avenue, Minneapolis, MN, USA", + ), + ).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/mothership-styled-suggestion-click.test.ts b/__tests__/workers/rpa/mothership-styled-suggestion-click.test.ts new file mode 100644 index 0000000..067a961 --- /dev/null +++ b/__tests__/workers/rpa/mothership-styled-suggestion-click.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { FIND_MOTHERSHIP_SUGGESTION_TARGET } from "@/workers/rpa/mothership-styled-suggestion-click"; + +describe("mothership-styled-suggestion-click", () => { + it("FIND_MOTHERSHIP_SUGGESTION_TARGET 为纯字符串(locator 点击路径)", () => { + expect(typeof FIND_MOTHERSHIP_SUGGESTION_TARGET).toBe("string"); + expect(FIND_MOTHERSHIP_SUGGESTION_TARGET).toContain("use-locator-click"); + }); +}); diff --git a/__tests__/workers/rpa/mothership.cargo.test.ts b/__tests__/workers/rpa/mothership.cargo.test.ts new file mode 100644 index 0000000..4efdd1a --- /dev/null +++ b/__tests__/workers/rpa/mothership.cargo.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Page } from "playwright"; + +const calls: string[] = []; + +vi.mock("@/workers/rpa/kernel/steps/cargo-step", () => ({ + fillCargoStep: vi.fn(async () => { + calls.push("cargo-step"); + }), +})); + +import { fillCargo } from "@/workers/rpa/mothership"; +import { fillCargoStep } from "@/workers/rpa/kernel/steps/cargo-step"; + +const probeReq = { + cargoHash: "t", + pickup: {} as never, + delivery: {} as never, + weightLb: 500, + dimsIn: { l: 48, w: 40, h: 48 }, + palletCount: 2, + cargoType: "general_freight", +}; + +describe("fillCargo thin wrapper", () => { + it("委托 kernel fillCargoStep", async () => { + calls.length = 0; + await fillCargo({} as Page, probeReq); + expect(calls).toEqual(["cargo-step"]); + expect(fillCargoStep).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/workers/rpa/mothership.mock.test.ts b/__tests__/workers/rpa/mothership.mock.test.ts new file mode 100644 index 0000000..5cc1d65 --- /dev/null +++ b/__tests__/workers/rpa/mothership.mock.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { MothershipRPAProvider } from "@/workers/rpa/mothership"; +import type { QuoteRequest } from "@/modules/providers/quote-provider"; + +describe("MothershipRPAProvider mock", () => { + beforeEach(() => { + process.env.RPA_MOCK_MODE = "true"; + }); + + it("mock 模式返回 4 档报价", async () => { + const provider = new MothershipRPAProvider(); + const req: QuoteRequest = { + cargoHash: "abc", + pickup: { + street: "1 Main", + city: "LA", + state: "CA", + zip: "90001", + placeId: "p1", + formattedAddress: "1 Main, LA, CA 90001", + selectedFromSuggestions: true, + mothershipOptionId: "ms_p1", + mothershipDisplayLabel: "1 Main, LA, CA 90001", + selectedFromMothership: true, + }, + delivery: { + street: "2 Oak", + city: "Dallas", + state: "TX", + zip: "75201", + placeId: "d1", + formattedAddress: "2 Oak, Dallas, TX 75201", + selectedFromSuggestions: true, + mothershipOptionId: "ms_d1", + mothershipDisplayLabel: "2 Oak, Dallas, TX 75201", + selectedFromMothership: true, + }, + weightLb: 500, + dimsIn: { l: 48, w: 40, h: 48 }, + palletCount: 2, + cargoType: "general_freight", + }; + + const result = await provider.getQuote(req); + expect(result.items).toHaveLength(4); + expect(result.sourceType).toBe("rpa"); + result.items.forEach((item) => { + expect(item.rawFreight).toBeGreaterThan(0); + expect(item.transitDays).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/workers/rpa/mothership.validate.test.ts b/__tests__/workers/rpa/mothership.validate.test.ts new file mode 100644 index 0000000..d635442 --- /dev/null +++ b/__tests__/workers/rpa/mothership.validate.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { validateResult } from "@/workers/rpa/mothership"; +import type { QuoteItem } from "@/modules/providers/quote-provider"; +import { RpaError } from "@/modules/rpa/errors"; + +function buildTier( + serviceLevel: QuoteItem["serviceLevel"], + rateOption: QuoteItem["rateOption"], + rawFreight = 100, +): QuoteItem { + return { + serviceLevel, + rateOption, + carrier: "Test", + transitDays: "3-5", + transitDescription: "3-5 business days", + rawFreight, + surcharges: 10, + rawTotal: rawFreight + 10, + }; +} + +describe("validateResult", () => { + it("四档完整 → 通过", () => { + const items = [ + buildTier("standard", "lowest"), + buildTier("standard", "fastest"), + buildTier("guaranteed", "lowest"), + buildTier("guaranteed", "fastest"), + ]; + expect(() => validateResult(items)).not.toThrow(); + }); + + it("仅 standard/guaranteed lowest → fastest 复用后通过", () => { + const items = [ + buildTier("standard", "lowest"), + buildTier("guaranteed", "lowest"), + ]; + expect(() => validateResult(items)).not.toThrow(); + }); + + it("仅 1 档 → RPA_DATA_INVALID", () => { + const items = [buildTier("standard", "lowest")]; + expect(() => validateResult(items)).toThrow(RpaError); + }); + + it("缺 guaranteed 整档 → RPA_DATA_INVALID", () => { + const items = [ + buildTier("standard", "lowest"), + buildTier("standard", "fastest"), + ]; + expect(() => validateResult(items)).toThrow(RpaError); + }); + + it("含 bestValue 扩展档 → 通过", () => { + const items = [ + buildTier("standard", "lowest"), + buildTier("standard", "fastest"), + buildTier("standard", "bestValue", 120), + buildTier("guaranteed", "lowest"), + buildTier("guaranteed", "fastest"), + buildTier("guaranteed", "bestValue", 130), + ]; + expect(() => validateResult(items)).not.toThrow(); + }); + + it("price=0 → RPA_DATA_INVALID", () => { + const items = [ + buildTier("standard", "lowest", 0), + buildTier("standard", "fastest"), + buildTier("guaranteed", "lowest"), + buildTier("guaranteed", "fastest"), + ]; + expect(() => validateResult(items)).toThrow(RpaError); + }); + + it("price=-1 → RPA_DATA_INVALID", () => { + const items = [ + buildTier("standard", "lowest", -1), + buildTier("standard", "fastest"), + buildTier("guaranteed", "lowest"), + buildTier("guaranteed", "fastest"), + ]; + expect(() => validateResult(items)).toThrow(RpaError); + }); +}); diff --git a/__tests__/workers/rpa/page-prep.test.ts b/__tests__/workers/rpa/page-prep.test.ts new file mode 100644 index 0000000..4d1bf16 --- /dev/null +++ b/__tests__/workers/rpa/page-prep.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import type { Page } from "playwright"; +import { shouldKeepBrowserOpen } from "@/lib/rpa/env"; +import { + dismissCookieConsent, + isQuoterWidgetHydrated, +} from "@/workers/rpa/page-prep"; + +describe("page-prep", () => { + const envBackup = { ...process.env }; + + afterEach(() => { + process.env = { ...envBackup }; + }); + + it("shouldKeepBrowserOpen:headed 默认保留", () => { + process.env.RPA_HEADLESS = "false"; + delete process.env.RPA_KEEP_BROWSER_OPEN; + expect(shouldKeepBrowserOpen()).toBe(true); + }); + + it("shouldKeepBrowserOpen:显式 false 关闭", () => { + process.env.RPA_HEADLESS = "false"; + process.env.RPA_KEEP_BROWSER_OPEN = "false"; + expect(shouldKeepBrowserOpen()).toBe(false); + }); + + it("dismissCookieConsent:点击 Accept All", async () => { + const click = vi.fn().mockResolvedValue(undefined); + const page = { + getByRole: vi.fn().mockReturnValue({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(true), + click, + }), + }), + locator: vi.fn().mockReturnValue({ + first: () => ({ + isVisible: vi.fn().mockResolvedValue(false), + click, + }), + }), + waitForTimeout: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(undefined), + } as unknown as Page; + + await dismissCookieConsent(page); + expect(click).toHaveBeenCalled(); + }); + + it("isQuoterWidgetHydrated:骨架屏文案无输入框时为 false", () => { + expect(isQuoterWidgetHydrated(0, "Sign up and book now")).toBe(false); + }); + + it("isQuoterWidgetHydrated:有可编辑输入框时为 true", () => { + expect(isQuoterWidgetHydrated(1, "Pick up from Search address")).toBe(true); + }); + + it("isQuoterWidgetHydrated:中文提货分区文案时 isQuoterWidgetHydrated 仍依赖输入框", () => { + expect(isQuoterWidgetHydrated(0, "从何处取货 送货至")).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/parked-quote-session.test.ts b/__tests__/workers/rpa/parked-quote-session.test.ts new file mode 100644 index 0000000..ca522dd --- /dev/null +++ b/__tests__/workers/rpa/parked-quote-session.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { BrowserContext, Page } from "playwright"; + +vi.mock("@/workers/rpa/page-prep", () => ({ + dismissCookieConsent: vi.fn().mockResolvedValue(undefined), + scrollQuoteWidgetIntoView: vi.fn().mockResolvedValue(undefined), +})); + +import { + hasParkedQuoteSession, + parkQuoteSession, + preheatParkedQuoteSession, + releaseAllParkedQuoteSessions, + releaseParkedQuoteSession, + takeParkedQuoteSession, + canPersistParkedQuoteSession, +} from "@/workers/rpa/parked-quote-session"; + +function mockSession(id: string) { + const page = { + close: vi.fn().mockResolvedValue(undefined), + } as unknown as Page; + const context = { + close: vi.fn().mockResolvedValue(undefined), + } as unknown as BrowserContext; + return { id, page, context }; +} + +describe("parked-quote-session", () => { + beforeEach(async () => { + vi.stubEnv("RPA_WORKER_ID", "test-worker-1"); + await releaseAllParkedQuoteSessions(); + }); + + afterEach(async () => { + await releaseAllParkedQuoteSessions(); + vi.unstubAllEnvs(); + }); + + it("park → take 接管同一会话", async () => { + const { id, page, context } = mockSession("sess-a"); + await parkQuoteSession(id, page, context); + expect(hasParkedQuoteSession(id)).toBe(true); + + const taken = await takeParkedQuoteSession(id); + expect(taken?.page).toBe(page); + expect(hasParkedQuoteSession(id)).toBe(false); + }); + + it("release 关闭 page/context", async () => { + const { id, page, context } = mockSession("sess-b"); + await parkQuoteSession(id, page, context); + expect(await releaseParkedQuoteSession(id)).toBe(true); + expect(page.close).toHaveBeenCalled(); + expect(context.close).toHaveBeenCalled(); + }); + + it("take 不存在的 session 返回 null", async () => { + expect(await takeParkedQuoteSession("missing")).toBeNull(); + }); + + it("preheat 对驻留 session 返回 true", async () => { + const { id, page, context } = mockSession("sess-c"); + await parkQuoteSession(id, page, context); + expect(await preheatParkedQuoteSession(id)).toBe(true); + }); + + it("canPersistParkedQuoteSession 需 RPA_WORKER_ID", () => { + vi.stubEnv("RPA_WORKER_ID", ""); + expect(canPersistParkedQuoteSession()).toBe(false); + vi.stubEnv("RPA_WORKER_ID", "w1"); + expect(canPersistParkedQuoteSession()).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/place-commit-strategy.test.ts b/__tests__/workers/rpa/place-commit-strategy.test.ts new file mode 100644 index 0000000..1a16ca7 --- /dev/null +++ b/__tests__/workers/rpa/place-commit-strategy.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { Page } from "playwright"; +import { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge"; + +vi.mock("@/lib/rpa/env", () => ({ + getRpaPlaceFixMode: vi.fn().mockReturnValue("auto"), +})); + +vi.mock("@/workers/rpa/fix-place/recon-helpers", () => ({ + loadReconVerdict: vi.fn().mockReturnValue({ + googleMapsLoaded: false, + reactFiberKey: "not-found", + placeChangedTriggerOk: false, + verdict: "PLAN_B", + }), +})); + +vi.mock("@/workers/rpa/mothership-styled-suggestion-click", () => ({ + clickMothershipStyledSuggestion: vi.fn(), + reopenStyledSuggestionDropdown: vi.fn(), + waitForStyledSuggestionRows: vi.fn(), +})); + +vi.mock("@/workers/rpa/fix-place/google-event-commit", () => ({ + commitViaGooglePlaceEvent: vi.fn(), +})); + +vi.mock("@/workers/rpa/fix-place/react-state-commit", () => ({ + commitViaReactStateInjection: vi.fn(), +})); + +vi.mock("@/workers/rpa/fix-place/browser-place-commit", () => ({ + commitViaBrowserPlaceFetch: vi.fn(), +})); + +vi.mock("@/workers/rpa/fix-place/cdp-pac-commit", () => ({ + commitViaCdpDomClick: vi.fn(), +})); + +vi.mock("@/workers/rpa/fix-place/direct-listener-commit", () => ({ + commitViaDirectPlaceChangedListener: vi.fn(), +})); + +vi.mock("@/workers/rpa/fix-place/place-diagnostic-probe", () => ({ + isPlaceDiagEnabled: vi.fn().mockReturnValue(false), + setPlaceDiagMark: vi.fn(), + logPlaceDiagnostic: vi.fn(), + writePlaceDiagnosticArtifact: vi.fn(), +})); + +import { commitPlaceWithStrategy } from "@/workers/rpa/fix-place/place-commit-strategy"; +import { clickMothershipStyledSuggestion } from "@/workers/rpa/mothership-styled-suggestion-click"; +import { commitViaBrowserPlaceFetch } from "@/workers/rpa/fix-place/browser-place-commit"; +import { commitViaCdpDomClick } from "@/workers/rpa/fix-place/cdp-pac-commit"; +import { commitViaDirectPlaceChangedListener } from "@/workers/rpa/fix-place/direct-listener-commit"; +import { commitViaGooglePlaceEvent } from "@/workers/rpa/fix-place/google-event-commit"; +import { commitViaReactStateInjection } from "@/workers/rpa/fix-place/react-state-commit"; + +describe("place-commit-strategy", () => { + const page = {} as Page; + let bridge: AxelSelectionBridge; + + beforeEach(() => { + vi.clearAllMocks(); + bridge = new AxelSelectionBridge(); + bridge.recordPlaceCommit("ChIJ_test"); + vi.spyOn(bridge, "waitForPlaceCommit").mockResolvedValue(true); + vi.spyOn(bridge, "hasSuccessfulPlaceNetworkRequest").mockReturnValue(true); + }); + + it("PLAN_B 时跳过 google-event 与 react-state", async () => { + vi.mocked(clickMothershipStyledSuggestion).mockResolvedValue({ + clicked: false, + reason: "miss", + }); + vi.mocked(commitViaCdpDomClick).mockResolvedValue({ + ok: false, + method: "cdp-dom-click-failed", + }); + vi.mocked(commitViaDirectPlaceChangedListener).mockResolvedValue({ + ok: false, + method: "direct-listener-invoke-failed", + }); + vi.mocked(commitViaBrowserPlaceFetch).mockResolvedValue({ + ok: false, + method: "browser-place-fetch", + }); + + const result = await commitPlaceWithStrategy( + page, + bridge, + "ChIJ_test", + "123 Test St, Los Angeles, CA, USA", + "pickup", + undefined, + { + input: {} as never, + query: { + street: "123 Test St", + city: "Los Angeles", + state: "CA", + zip: "", + }, + }, + ); + + expect(result.ok).toBe(false); + expect(commitViaGooglePlaceEvent).not.toHaveBeenCalled(); + expect(commitViaReactStateInjection).not.toHaveBeenCalled(); + }); + + it("styled-click 成功且 place 200 时直接返回", async () => { + vi.mocked(clickMothershipStyledSuggestion).mockResolvedValue({ + clicked: true, + method: "gold-exact-text", + matchedText: "123 Test St", + }); + + const result = await commitPlaceWithStrategy( + page, + bridge, + "ChIJ_test", + "123 Test St, Los Angeles, CA, USA", + "pickup", + undefined, + { + input: {} as never, + query: { + street: "123 Test St", + city: "Los Angeles", + state: "CA", + zip: "", + }, + }, + ); + + expect(result).toEqual({ ok: true, method: "gold-exact-text" }); + }); + + it("cdp-click 成功且 place 200 时返回 cdp 方法", async () => { + vi.mocked(clickMothershipStyledSuggestion).mockResolvedValue({ + clicked: false, + reason: "miss", + }); + vi.mocked(commitViaCdpDomClick).mockResolvedValue({ + ok: true, + method: "cdp-pac-item-0", + }); + vi.mocked(commitViaDirectPlaceChangedListener).mockResolvedValue({ + ok: false, + method: "direct-listener-no-network", + }); + vi.mocked(commitViaBrowserPlaceFetch).mockResolvedValue({ + ok: false, + method: "browser-place-fetch", + }); + + const result = await commitPlaceWithStrategy( + page, + bridge, + "ChIJ_test", + "123 Test St, Los Angeles, CA, USA", + "pickup", + undefined, + { + input: {} as never, + query: { + street: "123 Test St", + city: "Los Angeles", + state: "CA", + zip: "", + }, + }, + ); + + expect(result).toEqual({ ok: true, method: "cdp-pac-item-0" }); + expect(commitViaDirectPlaceChangedListener).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/workers/rpa/post-submit-navigation.test.ts b/__tests__/workers/rpa/post-submit-navigation.test.ts new file mode 100644 index 0000000..70fb925 --- /dev/null +++ b/__tests__/workers/rpa/post-submit-navigation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + isPostSubmitDestination, + POST_SUBMIT_URL_PATTERN, +} from "@/workers/rpa/quote-capture/post-submit-navigation"; + +describe("post-submit navigation", () => { + it("sign-up-quote 为合法落点", () => { + expect( + isPostSubmitDestination( + "https://www.mothership.com/sign-up-quote?quote-details=abc", + ), + ).toBe(true); + }); + + it("/quote 路径为合法落点", () => { + expect( + isPostSubmitDestination("https://www.mothership.com/quote/results"), + ).toBe(true); + }); + + it("首页不是合法落点", () => { + expect(isPostSubmitDestination("https://www.mothership.com/")).toBe(false); + }); + + it("POST_SUBMIT_URL_PATTERN 覆盖 sign-up-quote", () => { + expect(POST_SUBMIT_URL_PATTERN.test("sign-up-quote?x=1")).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/quote-business-ready.test.ts b/__tests__/workers/rpa/quote-business-ready.test.ts new file mode 100644 index 0000000..d0641aa --- /dev/null +++ b/__tests__/workers/rpa/quote-business-ready.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateBusinessReadyGates, + isBusinessReady, +} from "@/workers/rpa/quote-capture/quote-business-ready"; + +describe("quote business ready gates", () => { + const allOk = { + buttonEnabled: true, + cookieClear: true, + cargoLocked: true, + hydrationReady: true, + addressesLocked: true, + submitHitTestOk: true, + domStable: true, + }; + + it("全部闸门通过时 ready", () => { + const probe = evaluateBusinessReadyGates(allOk); + expect(isBusinessReady(probe)).toBe(true); + expect(probe.reasons).toHaveLength(0); + }); + + it("cookie 遮挡时 fail", () => { + const probe = evaluateBusinessReadyGates({ + ...allOk, + cookieClear: false, + submitHitTestOk: false, + }); + expect(isBusinessReady(probe)).toBe(false); + expect(probe.reasons.some((r) => r.includes("Cookie"))).toBe(true); + }); + + it("cargo 未锁定时 fail", () => { + const probe = evaluateBusinessReadyGates({ + ...allOk, + cargoLocked: false, + }); + expect(isBusinessReady(probe)).toBe(false); + expect(probe.reasons.some((r) => r.includes("货物"))).toBe(true); + }); + + it("按钮 disabled 时 fail", () => { + const probe = evaluateBusinessReadyGates({ + ...allOk, + buttonEnabled: false, + }); + expect(isBusinessReady(probe)).toBe(false); + expect(probe.reasons.some((r) => r.includes("disabled"))).toBe(true); + }); + + it("地址未锁定时 fail", () => { + const probe = evaluateBusinessReadyGates({ + ...allOk, + addressesLocked: false, + }); + expect(isBusinessReady(probe)).toBe(false); + expect(probe.reasons.some((r) => r.includes("地址"))).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/quote-capture.test.ts b/__tests__/workers/rpa/quote-capture.test.ts new file mode 100644 index 0000000..925ad90 --- /dev/null +++ b/__tests__/workers/rpa/quote-capture.test.ts @@ -0,0 +1,583 @@ +import { describe, expect, it } from "vitest"; +import { + AXEL_QUOTE_BODY, + AXEL_QUOTE_URL, +} from "@/__tests__/fixtures/axel-quote"; +import { + decodeAxelQuoteBodyLenient, + decodeQuoteDetailsParam, + decodeQuotePayloads, + decodeEmbeddedJsonBodies, +} from "@/workers/rpa/quote-capture/payload-decode"; +import { + formatPinnedTelemetry, + isJsonNetworkCandidate, + parsePinnedQuoteApiPatterns, +} from "@/workers/rpa/quote-capture/constants"; +import { + assertQuoteContract, + inspectQuoteContract, + satisfiesQuoteContract, +} from "@/workers/rpa/quote-capture/quote-contract-guard"; +import { validateQuoteSchema } from "@/workers/rpa/quote-capture/quote-schema-validator"; +import type { QuoteCaptureSignals } from "@/workers/rpa/quote-capture/types"; +import { RpaError } from "@/modules/rpa/errors"; + +const validRatesBody = { + rates: { + standard: { + lowest: { price: 612.72, transitDays: "5 business days" }, + fastest: { price: 694.02, transitDays: "3 business days" }, + }, + guaranteed: { + lowest: { price: 800, transitDays: "5 business days" }, + fastest: { price: 900, transitDays: "2 business days" }, + }, + }, +}; + +describe("quote contract guard", () => { + it("满足 rates.standard.lowest + rates.standard.fastest + rates.guaranteed", () => { + expect(satisfiesQuoteContract(validRatesBody)).toBe(true); + }); + + it("缺少 rates.guaranteed 时 fail", () => { + const result = inspectQuoteContract({ + rates: { + standard: { + lowest: { price: 1 }, + fastest: { price: 2 }, + }, + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.violations.some((v) => v.path.includes("guaranteed"))).toBe( + true, + ); + } + }); + + it("缺少 fastest 时 fail", () => { + const result = inspectQuoteContract({ + rates: { + standard: { lowest: { price: 1 } }, + guaranteed: { lowest: { price: 2 } }, + }, + }); + expect(result.ok).toBe(false); + }); + + it("assertQuoteContract 抛 RPA_DATA_INVALID", () => { + expect(() => assertQuoteContract({ foo: 1 }, "test-url")).toThrow(RpaError); + }); + + it("接受 rates.fastest 顶层", () => { + expect( + satisfiesQuoteContract({ + rates: { + standard: { lowest: { price: 10 } }, + fastest: { price: 20, transitDays: "2d" }, + guaranteed: { lowest: { price: 30 } }, + }, + }), + ).toBe(true); + }); + + it("接受 axel guaranteed.bestValue 作为 guaranteed 定价(阶段0 录证)", () => { + expect(satisfiesQuoteContract(AXEL_QUOTE_BODY)).toBe(true); + }); +}); + +describe("isJsonNetworkCandidate", () => { + it("JSON 响应通过(与 URL 路径无关)", () => { + expect( + isJsonNetworkCandidate( + "https://any-host.example/unknown-path", + "application/json", + ), + ).toBe(true); + }); + + it("忽略 segment 静态埋点", () => { + expect( + isJsonNetworkCandidate( + "https://api.segment.io/v1/track", + "application/json", + ), + ).toBe(false); + }); +}); + +describe("pinned telemetry", () => { + it("parsePinnedQuoteApiPatterns 仅用于校准日志", () => { + process.env.MOTHERSHIP_QUOTE_API_URL_PATTERNS = "api.mothership.com/quotes"; + expect( + formatPinnedTelemetry("https://api.mothership.com/quotes/x"), + ).toBe("pinned=match"); + expect(formatPinnedTelemetry("https://other.example/x")).toBe("pinned=miss"); + delete process.env.MOTHERSHIP_QUOTE_API_URL_PATTERNS; + }); + + it("未配置 pinned 时 telemetry 提示未配置", () => { + delete process.env.MOTHERSHIP_QUOTE_API_URL_PATTERNS; + expect(formatPinnedTelemetry("https://x")).toBe("pinned=未配置"); + }); +}); + +describe("decodeQuoteDetailsParam", () => { + it("解码 URL 编码 JSON(诊断用,非报价路径)", () => { + const inner = JSON.stringify({ + pickupLocation: { city: "Denver", zip: "80203" }, + freight: { quantity: 2, weightLbs: 500 }, + }); + const encoded = encodeURIComponent(inner); + expect(decodeQuoteDetailsParam(encoded)).toMatchObject({ + pickupLocation: { city: "Denver" }, + }); + }); +}); + +describe("decodeQuotePayloads", () => { + function signals(overrides: Partial): QuoteCaptureSignals { + return { + contractPayloads: [], + jsonPayloads: [], + resultUrl: "https://www.mothership.com/", + ...overrides, + }; + } + + it("data.rates 嵌套解析标准 Tab 档位", () => { + const result = decodeQuotePayloads( + signals({ + contractPayloads: [ + { + url: "https://api.example/graphql", + body: { data: { rates: validRatesBody.rates } }, + }, + ], + }), + ); + expect(result?.items).toHaveLength(4); + }); + + it("契约响应解析标准 Tab 档位", () => { + const result = decodeQuotePayloads( + signals({ + contractPayloads: [ + { url: "https://any.example/rates", body: validRatesBody }, + ], + }), + ); + expect(result?.source).toBe("contract"); + expect(result?.items).toHaveLength(4); + expect( + result?.items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "lowest", + )?.rawFreight, + ).toBe(612.72); + }); + + it("axel/quote 响应解析全部档位(标准 3 + guaranteed.bestValue)", () => { + const decoded = decodeQuotePayloads( + signals({ + contractPayloads: [{ url: AXEL_QUOTE_URL, body: AXEL_QUOTE_BODY }], + }), + ); + const items = validateQuoteSchema(decoded!.items); + expect(items).toHaveLength(4); + expect( + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "bestValue", + )?.rawFreight, + ).toBe(576.24); + expect( + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "lowest", + )?.rawFreight, + ).toBe(428.48); + expect( + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "fastest", + )?.transitDays, + ).toBe("Est. 3 business days"); + expect( + items.find( + (i) => i.serviceLevel === "guaranteed" && i.rateOption === "bestValue", + )?.rawFreight, + ).toBe(814.15); + }); + + it("2+2 动态档位:仅返回 API 存在的四档", () => { + const body = { + data: { + rates: { + standard: { + lowest: { finalPrice: 100, days: 4, serviceType: "lowest" }, + fastest: { finalPrice: 120, days: 2, serviceType: "fastest" }, + }, + guaranteed: { + lowest: { finalPrice: 200, days: 3, serviceType: "lowest" }, + bestValue: { finalPrice: 220, days: 2, serviceType: "bestValue" }, + }, + }, + }, + }; + const items = validateQuoteSchema( + decodeQuotePayloads( + signals({ + contractPayloads: [{ url: AXEL_QUOTE_URL, body }], + }), + )!.items, + ); + expect(items).toHaveLength(4); + }); + + it("基线 UI 截图五档:标准 3 + 保证送达 2(finalPrice)", () => { + const body = { + data: { + rates: { + standard: { + lowest: { finalPrice: 287.23, days: 4, serviceType: "lowest" }, + bestValue: { finalPrice: 345, days: 3, serviceType: "bestValue" }, + fastest: { finalPrice: 369.72, days: 2, serviceType: "fastest" }, + }, + guaranteed: { + lowest: { finalPrice: 457.36, days: 3, serviceType: "lowest" }, + bestValue: { finalPrice: 480.37, days: 2, serviceType: "bestValue" }, + }, + }, + availableRates: {}, + }, + }; + const decoded = decodeQuotePayloads( + signals({ + contractPayloads: [{ url: AXEL_QUOTE_URL, body }], + }), + ); + const items = validateQuoteSchema(decoded!.items); + expect(items).toHaveLength(5); + expect(items.find((i) => i.serviceLevel === "standard" && i.rateOption === "lowest")?.rawFreight).toBe(287.23); + expect(items.find((i) => i.serviceLevel === "standard" && i.rateOption === "bestValue")?.rawFreight).toBe(345); + expect(items.find((i) => i.serviceLevel === "standard" && i.rateOption === "fastest")?.rawFreight).toBe(369.72); + expect(items.find((i) => i.serviceLevel === "guaranteed" && i.rateOption === "lowest")?.rawFreight).toBe(457.36); + expect(items.find((i) => i.serviceLevel === "guaranteed" && i.rateOption === "bestValue")?.rawFreight).toBe(480.37); + }); + + it("availableRates extended 不覆盖 UI 叶节点 finalPrice", () => { + const body = { + data: { + rates: { + standard: { + lowest: { + id: "rate_x", + finalPrice: 287.23, + price: 478.46, + days: 4, + serviceType: "lowest", + }, + fastest: { + id: "rate_y", + finalPrice: 369.72, + price: 2069.46, + days: 2, + serviceType: "fastest", + }, + bestValue: { + id: "rate_z", + finalPrice: 345, + price: 500, + days: 3, + serviceType: "bestValue", + }, + }, + guaranteed: { + lowest: { finalPrice: 457.36, days: 3, serviceType: "lowest" }, + bestValue: { finalPrice: 480.37, days: 2, serviceType: "bestValue" }, + }, + }, + availableRates: { + rate_x: { + price: 478.46, + serviceType: "customRate", + serviceLevel: "standard", + }, + }, + }, + }; + const decoded = decodeQuotePayloads( + signals({ + contractPayloads: [{ url: "https://services.mothership.com/axel/quote", body }], + }), + ); + const items = validateQuoteSchema(decoded!.items); + expect(items).toHaveLength(5); + expect( + items.find((i) => i.serviceLevel === "standard" && i.rateOption === "lowest")?.rawFreight, + ).toBe(287.23); + expect( + items.find((i) => i.serviceLevel === "standard" && i.rateOption === "fastest")?.rawFreight, + ).toBe(369.72); + expect( + items.find((i) => i.serviceLevel === "standard" && i.rateOption === "bestValue")?.rawFreight, + ).toBe(345); + expect( + items.find((i) => i.serviceLevel === "guaranteed" && i.rateOption === "lowest")?.rawFreight, + ).toBe(457.36); + }); + + it("rates 已声明 UI 槽位时禁止 customRate 推断伪档位(3+1)", () => { + const body = { + data: { + rates: { + standard: { + lowest: { finalPrice: 310.01, days: 8, serviceType: "lowest" }, + bestValue: { finalPrice: 566.33, days: 5, serviceType: "bestValue" }, + fastest: { finalPrice: 768.88, days: 4, serviceType: "fastest" }, + }, + guaranteed: { + bestValue: { finalPrice: 698.81, days: 5, serviceType: "bestValue" }, + }, + }, + availableRates: { + junk_g1: { + finalPrice: 704.38, + days: 5, + serviceLevel: "guaranteed", + serviceType: "customRate", + carrierInfo: { name: "TForce" }, + }, + junk_g2: { + finalPrice: 566.33, + days: 2, + serviceLevel: "standard", + serviceType: "customRate", + carrierInfo: { name: "Daylight Transport" }, + }, + junk_g3: { + finalPrice: 650.99, + days: 6, + serviceLevel: "standard", + serviceType: "customRate", + carrierInfo: { name: "Roadrunner Transportation Service" }, + }, + }, + }, + }; + const items = validateQuoteSchema(decodeAxelQuoteBodyLenient(body)!); + expect(items).toHaveLength(4); + expect( + items.find( + (i) => i.serviceLevel === "guaranteed" && i.rateOption === "lowest", + ), + ).toBeUndefined(); + expect( + items.find( + (i) => i.serviceLevel === "guaranteed" && i.rateOption === "fastest", + ), + ).toBeUndefined(); + expect( + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "lowest", + )?.rawFreight, + ).toBe(310.01); + }); + + it("rates 声明 bestValue 槽位且叶节点为空时从 availableRates 补全", () => { + const body = { + data: { + rates: { + standard: { + lowest: { finalPrice: 503.98, days: 4, serviceType: "lowest" }, + fastest: { finalPrice: 597.89, days: 2, serviceType: "fastest" }, + bestValue: {}, + }, + guaranteed: { + bestValue: { finalPrice: 954.25, days: 2, serviceType: "bestValue" }, + }, + }, + availableRates: { + rate_bv: { + finalPrice: 545.5, + days: 3, + serviceLevel: "standard", + serviceType: "bestValue", + }, + }, + }, + }; + const items = validateQuoteSchema(decodeAxelQuoteBodyLenient(body)!); + expect(items).toHaveLength(4); + expect( + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "bestValue", + )?.rawFreight, + ).toBe(545.5); + }); + + it("rates.standard.fastest 为 id 引用时从 availableRates 解析", () => { + const body = { + data: { + rates: { + standard: { + lowest: { finalPrice: 310.01, days: 8, serviceType: "lowest" }, + bestValue: { finalPrice: 566.33, days: 5, serviceType: "bestValue" }, + fastest: { id: "rate_fast" }, + }, + guaranteed: { + bestValue: { finalPrice: 698.81, days: 5, serviceType: "bestValue" }, + }, + }, + availableRates: { + rate_fast: { + finalPrice: 768.88, + days: 4, + serviceLevel: "standard", + serviceType: "fastest", + carrierInfo: { name: "XPO Logistics" }, + }, + }, + }, + }; + const items = validateQuoteSchema(decodeAxelQuoteBodyLenient(body)!); + expect(items).toHaveLength(4); + expect( + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "fastest", + )?.rawFreight, + ).toBe(768.88); + }); + + it("standard 缺 fastest 键时从 availableRates 显式 serviceType 补档", () => { + const body = { + data: { + rates: { + standard: { + lowest: { finalPrice: 310.01, days: 8, serviceType: "lowest" }, + bestValue: { finalPrice: 566.33, days: 5, serviceType: "bestValue" }, + }, + guaranteed: { + bestValue: { finalPrice: 698.81, days: 5, serviceType: "bestValue" }, + }, + }, + availableRates: { + rate_fast: { + finalPrice: 768.88, + days: 4, + serviceLevel: "standard", + serviceType: "fastest", + }, + }, + }, + }; + const items = validateQuoteSchema(decodeAxelQuoteBodyLenient(body)!); + expect( + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "fastest", + )?.rawFreight, + ).toBe(768.88); + }); + + it("无契约响应返回 null", () => { + expect(decodeQuotePayloads(signals({}))).toBeNull(); + }); + + it("不满足契约且无法 lenient 解码时返回 null", () => { + expect( + decodeQuotePayloads( + signals({ + contractPayloads: [ + { url: "https://x", body: { rates: { standard: { lowest: { price: 1 } } } } }, + ], + }), + ), + ).toBeNull(); + }); + + it("sign-up-quote 页 axel/quote 契约不全时走 lenient", () => { + const body = { + data: { + rates: { standard: {}, guaranteed: {}, dedicated: {} }, + availableRates: { + rate_a: { + finalPrice: 612.72, + price: 600, + days: 3, + serviceLevel: "standard", + serviceType: "customRate", + }, + rate_b: { + finalPrice: 890.12, + price: 880, + days: 2, + serviceLevel: "standard", + serviceType: "customRate", + }, + }, + }, + }; + const decoded = decodeQuotePayloads( + signals({ + resultUrl: "https://www.mothership.com/sign-up-quote?quote-details=abc", + contractPayloads: [ + { url: "https://services.mothership.com/axel/quote", body }, + ], + }), + ); + expect(decoded?.source).toBe("lenient"); + expect( + decoded?.items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "lowest", + )?.rawFreight, + ).toBe(612.72); + }); +}); + +describe("decodeEmbeddedJsonBodies", () => { + it("已废弃,始终返回 null", () => { + expect(decodeEmbeddedJsonBodies([{ rates: validRatesBody }])).toBeNull(); + }); +}); + +describe("decodeAxelQuoteBodyLenient", () => { + it("rates 缺 standard/lowest 时从 availableRates customRate 推断", () => { + const body = { + data: { + rates: { + standard: {}, + guaranteed: {}, + dedicated: {}, + }, + availableRates: { + rate_a: { + finalPrice: 927.48, + price: 922.49, + days: 3, + serviceLevel: "standard", + serviceType: "customRate", + carrierInfo: { name: "ABF Freight" }, + }, + rate_b: { + finalPrice: 1100.12, + price: 1095.13, + days: 2, + serviceLevel: "standard", + serviceType: "customRate", + carrierInfo: { name: "Saia" }, + }, + }, + }, + }; + const items = decodeAxelQuoteBodyLenient(body); + expect(items).not.toBeNull(); + expect( + items!.find((i) => i.serviceLevel === "standard" && i.rateOption === "lowest") + ?.rawTotal, + ).toBe(927.48); + expect( + items!.find((i) => i.serviceLevel === "standard" && i.rateOption === "fastest") + ?.rawTotal, + ).toBe(1100.12); + }); +}); diff --git a/__tests__/workers/rpa/quote-normalize.test.ts b/__tests__/workers/rpa/quote-normalize.test.ts new file mode 100644 index 0000000..0d758c3 --- /dev/null +++ b/__tests__/workers/rpa/quote-normalize.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeQuote, + resolveRatesFromBody, +} from "@/workers/rpa/quote-capture/quote-normalize"; +import { satisfiesQuoteContract } from "@/workers/rpa/quote-capture/quote-contract-guard"; + +const rates = { + standard: { + lowest: { price: 100, transitDays: "5d" }, + fastest: { price: 120, transitDays: "3d" }, + }, + guaranteed: { + lowest: { price: 200, transitDays: "5d" }, + fastest: { price: 220, transitDays: "2d" }, + }, +}; + +describe("resolveRatesFromBody", () => { + it("body.rates", () => { + expect(resolveRatesFromBody({ rates })?.sourcePath).toBe("rates"); + }); + + it("body.data.rates", () => { + expect(resolveRatesFromBody({ data: { rates } })?.sourcePath).toBe( + "data.rates", + ); + }); + + it("body.result.rates", () => { + expect(resolveRatesFromBody({ result: { rates } })?.sourcePath).toBe( + "result.rates", + ); + }); + + it("array[0].rates", () => { + expect(resolveRatesFromBody([{ rates }])?.sourcePath).toBe("[0].rates"); + }); +}); + +describe("normalizeQuote + contract", () => { + it("data.rates 通过契约", () => { + expect(satisfiesQuoteContract({ data: { rates } })).toBe(true); + }); + + it("无 rates 返回 null", () => { + expect(normalizeQuote({ foo: 1 })).toBeNull(); + }); +}); diff --git a/__tests__/workers/rpa/quote-page-adapter.test.ts b/__tests__/workers/rpa/quote-page-adapter.test.ts new file mode 100644 index 0000000..26de63f --- /dev/null +++ b/__tests__/workers/rpa/quote-page-adapter.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { Page } from "playwright"; +import { RPA_SESSION_WIDGET_TIMEOUT_MS } from "@/lib/constants/rpa"; +import { MothershipQuotePageAdapter } from "@/workers/rpa/quote-page-adapter"; +import { RpaError } from "@/modules/rpa/errors"; +import { waitForQuoteWidgetReady } from "@/workers/rpa/address-side"; + +vi.mock("@/workers/rpa/page-prep", () => ({ + stabilizeQuoteLandingPage: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/workers/rpa/address-side", () => ({ + waitForQuoteWidgetReady: vi.fn().mockResolvedValue(undefined), + ensureQuoteFormVisible: vi.fn().mockResolvedValue(undefined), +})); + +function mockPage(overrides: { + url?: string; + goto?: ReturnType; + pickupVisible?: boolean; + submitVisible?: boolean; + bodyText?: string; + cfCount?: number; +}): Page { + const url = overrides.url ?? "https://quote.example.com/"; + const bodyLocator = { + innerText: vi.fn().mockResolvedValue(overrides.bodyText ?? "Quote form"), + }; + return { + url: () => url, + goto: overrides.goto ?? vi.fn().mockResolvedValue(undefined), + waitForLoadState: vi.fn().mockResolvedValue(undefined), + locator: vi.fn((selector: string) => { + if (selector === "body") { + return bodyLocator; + } + return { + first: () => ({ + isVisible: vi.fn().mockImplementation(async () => { + if (selector.includes("pickup")) { + return overrides.pickupVisible ?? true; + } + if (selector.includes("submit")) { + return overrides.submitVisible ?? true; + } + return false; + }), + innerText: vi.fn().mockResolvedValue(overrides.bodyText ?? "Quote form"), + }), + count: vi.fn().mockResolvedValue(overrides.cfCount ?? 0), + }; + }), + } as unknown as Page; +} + +describe("MothershipQuotePageAdapter", () => { + const adapter = new MothershipQuotePageAdapter(); + const envBackup = { ...process.env }; + + beforeEach(() => { + process.env.MOTHERSHIP_EMAIL = ""; + process.env.MOTHERSHIP_PASSWORD = ""; + process.env.RPA_SELECTOR_PICKUP_STREET = '[data-testid="pickup"]'; + process.env.RPA_SELECTOR_SUBMIT = 'button[type="submit"]'; + }); + + afterEach(() => { + process.env = { ...envBackup }; + }); + + it("isLoginWithoutCredentials:login 且无凭据", () => { + const page = mockPage({ url: "https://example.com/login" }); + expect(adapter.isLoginWithoutCredentials(page)).toBe(true); + }); + + it("resolveQuoteEntry:首个 URL 通过 preCheck", async () => { + process.env.MOTHERSHIP_QUOTE_URLS = + "https://good.example.com,https://bad.example.com"; + process.env.RPA_SELECTOR_PICKUP_SECTION = "text=pickup"; + const page = mockPage({ + pickupVisible: true, + submitVisible: false, + }); + + const result = await adapter.resolveQuoteEntry(page); + expect(result.activeUrl).toBe("https://good.example.com"); + expect(page.goto).toHaveBeenCalledTimes(1); + }); + + it("resolveQuoteEntry:全部失败 → QUOTE_ENTRY_UNAVAILABLE", async () => { + process.env.MOTHERSHIP_QUOTE_URLS = + "https://bad1.example.com,https://bad2.example.com"; + const page = mockPage({ + pickupVisible: false, + submitVisible: false, + }); + vi.mocked(page.goto).mockRejectedValue(new Error("net err")); + + await expect(adapter.resolveQuoteEntry(page)).rejects.toMatchObject({ + code: "QUOTE_ENTRY_UNAVAILABLE", + }); + expect(page.goto).toHaveBeenCalled(); + }); +}); + +describe("MothershipQuotePageAdapter preCheck", () => { + const adapter = new MothershipQuotePageAdapter(); + + beforeEach(() => { + process.env.RPA_SELECTOR_PICKUP_STREET = '[data-testid="pickup"]'; + process.env.RPA_SELECTOR_SUBMIT = 'button[type="submit"]'; + }); + + it("login 无凭据 → STRUCT_CHANGE", async () => { + process.env.MOTHERSHIP_EMAIL = ""; + const page = mockPage({ url: "https://example.com/login" }); + await expect(adapter.preCheck(page)).rejects.toMatchObject({ + code: "STRUCT_CHANGE", + }); + }); + + it("reuseSession 时使用较短 widget 等待", async () => { + const page = mockPage({ pickupVisible: true }); + await adapter.preCheck(page, { reuseSession: true }); + expect(waitForQuoteWidgetReady).toHaveBeenCalledWith( + page, + RPA_SESSION_WIDGET_TIMEOUT_MS, + ); + }); +}); diff --git a/__tests__/workers/rpa/quote-pipeline-gate.test.ts b/__tests__/workers/rpa/quote-pipeline-gate.test.ts new file mode 100644 index 0000000..94975f7 --- /dev/null +++ b/__tests__/workers/rpa/quote-pipeline-gate.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import type { Page } from "playwright"; +import { runQuoteCapturePipeline } from "@/workers/rpa/quote-capture/pipeline"; + +describe("runQuoteCapturePipeline gate", () => { + it("未带 widgetFallbackAfterDirectFailure 时拒绝启动 Widget", async () => { + await expect( + runQuoteCapturePipeline({} as Page), + ).rejects.toMatchObject({ + message: expect.stringContaining("Direct 明确失败后"), + }); + }); +}); diff --git a/__tests__/workers/rpa/quote-strategy.test.ts b/__tests__/workers/rpa/quote-strategy.test.ts new file mode 100644 index 0000000..82b351e --- /dev/null +++ b/__tests__/workers/rpa/quote-strategy.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { QuoteItem, QuoteRequest } from "@/modules/providers/quote-provider"; +import { RpaError } from "@/modules/rpa/errors"; +import type { RPAContext } from "@/workers/rpa/kernel/context"; + +const directMock = vi.fn(); +const widgetMock = vi.fn(); + +vi.mock("@/workers/rpa/quote-capture/axel-quote-direct", () => ({ + scrapeQuotesViaDirectAxel: (...args: unknown[]) => directMock(...args), +})); + +vi.mock("@/workers/rpa/quote-capture/quote-schema-validator", () => ({ + validateQuoteSchema: (items: QuoteItem[]) => items, +})); + +vi.mock("@/lib/rpa/env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isWidgetQuoteFallbackEnabled: vi.fn(() => true), + }; +}); + +vi.mock("@/workers/rpa/kernel/steps/quote-step", () => ({ + scrapeQuotesStep: (...args: unknown[]) => widgetMock(...args), +})); + +import { isWidgetQuoteFallbackEnabled } from "@/lib/rpa/env"; + +import { captureQuotesDirectFirst } from "@/workers/rpa/quote-capture/quote-strategy"; + +function sampleItems(): QuoteItem[] { + return [ + { + serviceLevel: "standard", + rateOption: "lowest", + carrier: "Test", + transitDays: "3-5", + transitDescription: "3-5 business days", + rawFreight: 100, + surcharges: 10, + rawTotal: 110, + }, + ]; +} + +function makeCtx(): RPAContext { + return { + page: {} as RPAContext["page"], + state: { + axelPlaceLocations: { + pickup: { placeId: "ChIJ-pickup", description: "pickup" }, + delivery: { placeId: "ChIJ-delivery", description: "delivery" }, + }, + }, + } as RPAContext; +} + +const req = {} as QuoteRequest; + +describe("captureQuotesDirectFirst", () => { + beforeEach(() => { + directMock.mockReset(); + widgetMock.mockReset(); + vi.mocked(isWidgetQuoteFallbackEnabled).mockReturnValue(true); + }); + + it("Direct 成功时不调用 Widget", async () => { + directMock.mockResolvedValue(sampleItems()); + const result = await captureQuotesDirectFirst(makeCtx(), req); + expect(result.source).toBe("direct"); + expect(widgetMock).not.toHaveBeenCalled(); + }); + + it("Direct 失败且允许 fallback 时才调用 Widget", async () => { + directMock.mockRejectedValue(new RpaError("RPA_DATA_INVALID", "direct fail")); + widgetMock.mockResolvedValue(sampleItems()); + const result = await captureQuotesDirectFirst(makeCtx(), req); + expect(result.source).toBe("widget"); + expect(widgetMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ widgetFallbackAfterDirectFailure: true }), + ); + }); + + it("关闭 Widget fallback 时 Direct 失败直接抛错", async () => { + vi.mocked(isWidgetQuoteFallbackEnabled).mockReturnValue(false); + directMock.mockRejectedValue(new RpaError("RPA_DATA_INVALID", "direct fail")); + await expect(captureQuotesDirectFirst(makeCtx(), req)).rejects.toThrow( + /direct fail/, + ); + expect(widgetMock).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/workers/rpa/selection-trace.test.ts b/__tests__/workers/rpa/selection-trace.test.ts new file mode 100644 index 0000000..303ef4a --- /dev/null +++ b/__tests__/workers/rpa/selection-trace.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { + formatSelectionTraceEvent, + isMothershipSuggestionClickTarget, + pickSelectionGoldInteractions, + type SelectionTraceSnapshot, +} from "@/workers/rpa/selection-trace"; + +function baseSnapshot( + overrides: Partial = {}, +): SelectionTraceSnapshot { + return { + label: "test", + at: 1, + activeElement: { + tagName: "INPUT", + role: "combobox", + className: "pac-target-input", + id: "", + ariaExpanded: "true", + ariaAutocomplete: "list", + ariaActivedescendant: null, + placeholder: "Search", + valuePreview: "Duval Street", + isPacTarget: true, + }, + activeDescendant: null, + pac: { visible: true, itemCount: 1, highlightedIndex: -1, items: [] }, + listboxOptions: [], + widgetInputs: [], + comboboxAlive: false, + ...overrides, + }; +} + +describe("selection-trace", () => { + it("CAPTURE_SELECTION_TRACE 拼接后可被解析执行", () => { + const label = "manual-pickup-done"; + const expr = `(function(label) { + return { label: label, at: 1, ok: true }; + })(${JSON.stringify(label)})`; + const result = new Function(`return ${expr}`)() as { label: string; ok: boolean }; + expect(result.ok).toBe(true); + expect(result.label).toBe(label); + }); + + it("isMothershipSuggestionClickTarget 识别 99-ff22eee0 金样本 sc-bBXraC", () => { + expect( + isMothershipSuggestionClickTarget({ + tagName: "DIV", + role: null, + className: "sc-bBXraC jcUlJN", + id: "", + textPreview: + "La Concha Key West, Autograph CollectionDuval Street, Key West, Florida, USA", + closestOption: null, + closestPacItem: null, + dataOptionId: null, + closestMothershipSuggestion: null, + }), + ).toBe(true); + expect( + isMothershipSuggestionClickTarget({ + tagName: "DIV", + role: null, + className: "sc-fubEtJ jSdUjy", + id: "", + textPreview: "Pick up from", + closestOption: null, + closestPacItem: null, + dataOptionId: null, + closestMothershipSuggestion: null, + }), + ).toBe(false); + }); + + it("pickSelectionGoldInteractions 识别 option/pac 点击", () => { + const gold = pickSelectionGoldInteractions({ + clicks: [ + { + kind: "click", + at: 1, + target: { + tagName: "DIV", + role: null, + className: "sc-foo", + id: "", + textPreview: "La Concha", + closestOption: "LI#opt.role-option", + closestPacItem: null, + dataOptionId: "2", + closestMothershipSuggestion: null, + }, + }, + ], + keys: [ + { + kind: "keydown", + at: 2, + key: "Enter", + target: { + tagName: "BUTTON", + role: null, + className: "", + id: "", + textPreview: "", + closestOption: null, + closestPacItem: null, + dataOptionId: null, + }, + }, + { + kind: "keydown", + at: 3, + key: "Enter", + target: { + tagName: "INPUT", + role: null, + className: "sc-input", + id: "", + textPreview: "", + closestOption: null, + closestPacItem: null, + dataOptionId: null, + }, + }, + ], + }); + expect(gold.selectionClicks).toHaveLength(1); + expect(gold.selectionKeys).toHaveLength(1); + expect(gold.selectionKeys[0]?.key).toBe("Enter"); + }); + + it("formatSelectionTraceEvent:aria-activedescendant=null 标记 combobox-dead", () => { + const event = formatSelectionTraceEvent(baseSnapshot()); + expect(event).toContain("aria-activedescendant=null"); + expect(event).toContain("combobox-dead"); + expect(event).toContain("pac-highlight=none"); + }); + + it("formatSelectionTraceEvent:有 activeDescendant 时 combobox-alive", () => { + const event = formatSelectionTraceEvent( + baseSnapshot({ + activeDescendant: { + id: "option-0", + tagName: "DIV", + role: "option", + className: "", + ariaSelected: "true", + textPreview: "La Concha", + }, + comboboxAlive: true, + pac: { visible: true, itemCount: 2, highlightedIndex: 0, items: [] }, + }), + ); + expect(event).toContain("aria-activedescendant=option-0"); + expect(event).toContain("combobox-alive"); + expect(event).toContain("pac-highlight=0"); + }); +}); diff --git a/__tests__/workers/timeout-sweeper.test.ts b/__tests__/workers/timeout-sweeper.test.ts new file mode 100644 index 0000000..9043eab --- /dev/null +++ b/__tests__/workers/timeout-sweeper.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + alertLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/modules/cache/redis-cache", () => ({ + getL3: vi.fn(), +})); + +vi.mock("@/modules/pricing/engine", () => ({ + getMarkupPercent: vi.fn().mockResolvedValue(0), + applyMarkupToQuotes: vi.fn((q: unknown[]) => q), +})); + +import { prisma } from "@/lib/prisma"; +import { getL3 } from "@/modules/cache/redis-cache"; +import { + sweepTimedOutQuote, + sweepTimedOutQuotes, +} from "@/workers/scheduler/timeout-sweeper"; + +const FOUR_TIERS = [ + { service_level: "standard", rate_option: "lowest", raw_freight: 320, surcharges: 0, raw_total: 320, carrier: "M", transit_days: "5", transit_description: "x" }, + { service_level: "standard", rate_option: "fastest", raw_freight: 365, surcharges: 0, raw_total: 365, carrier: "M", transit_days: "3", transit_description: "x" }, + { service_level: "guaranteed", rate_option: "lowest", raw_freight: 380, surcharges: 0, raw_total: 380, carrier: "M", transit_days: "4", transit_description: "x" }, + { service_level: "guaranteed", rate_option: "fastest", raw_freight: 410, surcharges: 0, raw_total: 410, carrier: "M", transit_days: "3", transit_description: "x" }, +]; + +const mockedFindFirst = vi.mocked(prisma.quoteRecord.findFirst); +const mockedUpdate = vi.mocked(prisma.quoteRecord.update); +const mockedGetL3 = vi.mocked(getL3); +const mockedAlertCreate = vi.mocked(prisma.alertLog.create); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("sweepTimedOutQuote", () => { + it("无 L3 → failed + QUOTE_TIMEOUT", async () => { + mockedFindFirst.mockResolvedValue({ + quoteId: "QTE_001", + cargoHash: "hash1", + customerId: "CUST_001", + createdAt: new Date(Date.now() - 421_000), + } as never); + mockedGetL3.mockResolvedValue(null); + + await sweepTimedOutQuote("QTE_001"); + + expect(mockedUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "failed", + errorCode: "QUOTE_TIMEOUT", + }), + }), + ); + expect(mockedAlertCreate).toHaveBeenCalled(); + }); + + it("有 L3 → done stale", async () => { + mockedFindFirst.mockResolvedValue({ + quoteId: "QTE_002", + cargoHash: "hash2", + customerId: "CUST_001", + createdAt: new Date(Date.now() - 421_000), + } as never); + mockedGetL3.mockResolvedValue({ quotes: FOUR_TIERS }); + + await sweepTimedOutQuote("QTE_002"); + + expect(mockedUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "done", + sourceType: "stale", + isRealtime: false, + }), + }), + ); + }); + + it("未超时 → 不处理", async () => { + mockedFindFirst.mockResolvedValue({ + quoteId: "QTE_003", + cargoHash: "hash3", + customerId: "CUST_001", + createdAt: new Date(Date.now() - 5_000), + } as never); + + await sweepTimedOutQuote("QTE_003"); + + expect(mockedUpdate).not.toHaveBeenCalled(); + }); +}); + +describe("sweepTimedOutQuotes", () => { + it("批量扫描返回处理数量", async () => { + vi.mocked(prisma.quoteRecord.findMany).mockResolvedValue([ + { quoteId: "QTE_A" }, + { quoteId: "QTE_B" }, + ] as never); + mockedFindFirst.mockResolvedValue(null); + + const count = await sweepTimedOutQuotes(); + expect(count).toBe(2); + }); +}); diff --git a/app/403/page.tsx b/app/403/page.tsx new file mode 100644 index 0000000..25b2169 --- /dev/null +++ b/app/403/page.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { PrimaryButton } from "@/components/ui/primary-button"; + +export default function ForbiddenPage() { + return ( +
+

403

+

无访问权限

+

+ 您没有权限访问此页面,请联系管理员。 +

+ + 返回工作台 + +
+ ); +} diff --git a/app/admin/alerts/page.tsx b/app/admin/alerts/page.tsx new file mode 100644 index 0000000..ea3ffb8 --- /dev/null +++ b/app/admin/alerts/page.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { AdminLayout } from "@/components/layout/admin-layout"; +import { PageHeader } from "@/components/layout/page-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { SecondaryButton } from "@/components/ui/primary-button"; +import { PrimaryButton } from "@/components/ui/primary-button"; +import { SelectField } from "@/components/ui/select-field"; +import { Card } from "@/components/ui/card"; +import { + ALERT_TYPE_LABEL, + ALERT_TYPE_TONE, +} from "@/lib/frontend/constants"; +import { formatDateTime } from "@/lib/frontend/format"; +import { + adminGetAlerts, + adminResolveAlert, +} from "@/lib/frontend/api-client"; +import { useAuth } from "@/hooks/use-auth"; +import type { AlertRecord, AlertType, DataPageStatus } from "@/lib/frontend/types"; + +const PAGE_SIZE = 10; + +const TYPE_OPTIONS = [ + { value: "", label: "全部类型" }, + ...( + Object.keys(ALERT_TYPE_LABEL) as AlertType[] + ).map((t) => ({ value: t, label: ALERT_TYPE_LABEL[t] })), +]; + +const STATUS_OPTIONS = [ + { value: "", label: "全部状态" }, + { value: "open", label: "待处理" }, + { value: "resolved", label: "已处理" }, +]; + +function toneClass(tone: "warning" | "error" | "neutral"): string { + if (tone === "warning") return "bg-amber-100 text-warning"; + if (tone === "error") return "bg-red-100 text-error"; + return "bg-slate-100 text-text-secondary"; +} + +export default function AlertsPage() { + const { token, user } = useAuth(); + const [status, setStatus] = useState("loading"); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [typeFilter, setTypeFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("open"); + const [detail, setDetail] = useState(null); + const [resolving, setResolving] = useState(false); + + const load = useCallback( + async (p: number, type: string, st: string) => { + if (!token) return; + setStatus("loading"); + const res = await adminGetAlerts("", token, p, PAGE_SIZE, type || undefined, st || undefined); + if (res.code !== 0) { + setStatus("error"); + return; + } + setRows(res.data.list); + setTotal(res.data.total); + setStatus(res.data.total === 0 ? "empty" : "success"); + }, + [token], + ); + + useEffect(() => { + void load(page, typeFilter, statusFilter); + }, [load, page, typeFilter, statusFilter]); + + const handleResolve = async (alert: AlertRecord) => { + if (!token || !user) return; + setResolving(true); + const res = await adminResolveAlert("", token, alert.id, user.user_id); + setResolving(false); + if (res.code !== 0) return; + setRows((list) => + list.map((a) => + a.id === alert.id + ? { ...a, status: "resolved", resolved_at: new Date().toISOString() } + : a, + ), + ); + setDetail(null); + }; + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( + + +
+ { + setPage(1); + setTypeFilter(e.target.value); + }} + /> +
+
+ { + setPage(1); + setStatusFilter(e.target.value); + }} + /> +
+ + } + /> + + {status === "loading" && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + {status === "error" && ( + void load(page, typeFilter, statusFilter)}> + 重试 + + } + > + 加载预警失败 + + )} + {status === "empty" && ( + +

暂无符合条件的预警

+
+ )} + + {status === "success" && ( +
+
+ + + + + + + + + + + {rows.map((row) => ( + setDetail(row)} + > + + + + + + ))} + +
时间类型报价单号状态
+ {formatDateTime(row.created_at)} + + + {ALERT_TYPE_LABEL[row.alert_type]} + + + {row.quote_id ?? "—"} + + {row.status === "open" ? ( + 待处理 + ) : ( + 已处理 + )} +
+
+
+ 共 {total} 条 +
+ setPage((p) => p - 1)} + > + 上一页 + + + {page} / {totalPages} + + = totalPages} + onClick={() => setPage((p) => p + 1)} + > + 下一页 + +
+
+
+ )} + + {detail && ( +
!resolving && setDetail(null)} + > +
e.stopPropagation()} + > +

预警详情

+
+
+
预警编号
+
{detail.id}
+
+
+
报价单号
+
{detail.quote_id ?? "—"}
+
+
+
触发时间
+
{formatDateTime(detail.created_at)}
+
+
+
+

+ 详情数据 +

+
+                {JSON.stringify(detail.detail_json, null, 2)}
+              
+
+
+ setDetail(null)}> + 关闭 + + {(detail.alert_type === "RPA_FAILED" || + detail.alert_type === "RPA_CAPTCHA") && ( + + 跳转 RPA 暂停 + + )} + {detail.status === "open" && ( + void handleResolve(detail)} + > + 标记已处理 + + )} +
+
+
+ )} +
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..b70f613 --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AdminLayout } from "@/components/layout/admin-layout"; +import { PageHeader } from "@/components/layout/page-header"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { SecondaryButton } from "@/components/ui/primary-button"; +import { METRICS_THRESHOLDS } from "@/lib/frontend/constants"; +import { adminGetMetrics } from "@/lib/frontend/api-client"; +import { useAuth } from "@/hooks/use-auth"; +import type { DataPageStatus, MetricsDashboard } from "@/lib/frontend/types"; + +function MetricCard({ + label, + value, + suffix, + threshold, + higherIsBetter = true, +}: { + label: string; + value: number; + suffix?: string; + threshold?: number; + higherIsBetter?: boolean; +}) { + const below = + threshold !== undefined && + (higherIsBetter ? value < threshold : value > threshold); + + return ( + +

{label}

+

+ {value} + {suffix} +

+
+ ); +} + +export default function MetricsDashboardPage() { + const { token } = useAuth(); + const [status, setStatus] = useState("loading"); + const [metrics, setMetrics] = useState(null); + + const load = async () => { + if (!token) return; + setStatus("loading"); + const res = await adminGetMetrics("", token); + if (res.code !== 0) { + setStatus("error"); + return; + } + setMetrics(res.data); + setStatus("success"); + }; + + useEffect(() => { + void load(); + }, [token]); + + return ( + + + + {status === "loading" && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + {status === "error" && ( + void load()}>重试} + > + 加载指标失败 + + )} + + {status === "success" && metrics && ( +
+
+ + + + +
+
+ +

P95 延迟

+

+ {metrics.p95_latency_ms} ms +

+
+ +

队列深度

+

+ {metrics.queue_depth} +

+
+ +

未处理预警

+

0 ? "text-warning" : "" + }`} + > + {metrics.open_alerts_count} +

+
+
+
+ )} +
+ ); +} diff --git a/app/admin/markup/page.tsx b/app/admin/markup/page.tsx new file mode 100644 index 0000000..c5d7047 --- /dev/null +++ b/app/admin/markup/page.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AdminLayout } from "@/components/layout/admin-layout"; +import { PageHeader } from "@/components/layout/page-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { SecondaryButton } from "@/components/ui/primary-button"; +import { PrimaryButton } from "@/components/ui/primary-button"; +import { InputField } from "@/components/ui/input-field"; +import { SelectField } from "@/components/ui/select-field"; +import { Card } from "@/components/ui/card"; +import { + adminGetMarkupConfigs, + adminUpdateMarkupConfig, +} from "@/lib/frontend/api-client"; +import { formatDateTime, formatPercent, formatUSD } from "@/lib/frontend/format"; +import { useAuth } from "@/hooks/use-auth"; +import type { DataPageStatus, MarkupConfig, MarkupType } from "@/lib/frontend/types"; + +const PAGE_SIZE = 10; + +function formatMarkupSummary(row: MarkupConfig): string { + if (row.markup_type === "fixed") { + const amount = row.markup_fixed_amount ?? 0; + return amount > 0 ? `固定 ${formatUSD(amount)}` : "未配置"; + } + return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%"; +} + +export default function AdminMarkupPage() { + const { token, user } = useAuth(); + const [status, setStatus] = useState("loading"); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [keyword, setKeyword] = useState(""); + + const [editing, setEditing] = useState(null); + const [markupType, setMarkupType] = useState("percent"); + const [percent, setPercent] = useState(""); + const [fixedAmount, setFixedAmount] = useState(""); + const [remark, setRemark] = useState(""); + const [saving, setSaving] = useState(false); + const [fieldError, setFieldError] = useState(); + + const load = useCallback( + async (p: number, kw: string) => { + if (!token) return; + setStatus("loading"); + const res = await adminGetMarkupConfigs("", token, p, PAGE_SIZE, kw); + if (res.code !== 0) { + setStatus("error"); + return; + } + setRows(res.data.list); + setTotal(res.data.total); + setStatus(res.data.total === 0 ? "empty" : "success"); + }, + [token], + ); + + useEffect(() => { + void load(page, keyword); + }, [load, page, keyword]); + + const openEdit = (row: MarkupConfig) => { + setEditing(row); + setMarkupType(row.markup_type); + setPercent(String(row.markup_percent)); + setFixedAmount( + row.markup_fixed_amount !== null ? String(row.markup_fixed_amount) : "", + ); + setRemark(row.remark ?? ""); + setFieldError(undefined); + }; + + const handleSave = async () => { + if (!editing || !token || !user) return; + + if (markupType === "percent") { + const n = Number(percent); + if (Number.isNaN(n) || n < 0 || n > 30) { + setFieldError("加价比例须在 0~30% 之间"); + return; + } + } else { + const n = Number(fixedAmount); + if (Number.isNaN(n) || n < 0) { + setFieldError("固定加价金额须 ≥ 0"); + return; + } + } + + setSaving(true); + const res = await adminUpdateMarkupConfig("", token, editing.customer_id, { + markup_type: markupType, + markup_percent: + markupType === "percent" ? Number(percent) : undefined, + markup_fixed_amount: + markupType === "fixed" ? Number(fixedAmount) : undefined, + remark, + }); + setSaving(false); + + if (res.code !== 0) { + setFieldError(res.message); + return; + } + + setRows((list) => + list.map((r) => (r.customer_id === res.data.customer_id ? res.data : r)), + ); + setEditing(null); + }; + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( + + + { + setPage(1); + setKeyword(e.target.value); + }} + /> + + } + /> + + {status === "loading" && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {status === "error" && ( + void load(page, keyword)}> + 重试 + + } + > + 加载加价配置失败 + + )} + + {status === "empty" && ( + + 未找到匹配的客户 + + )} + + {status === "success" && ( +
+
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
客户 ID加价方式加价规则操作人备注更新时间操作
{row.customer_id} + {row.markup_type === "fixed" ? "固定金额" : "百分比"} + + {formatMarkupSummary(row)} + + {row.operator_id || "—"} + + {row.remark || "—"} + + {row.operator_id + ? formatDateTime(row.updated_at) + : "—"} + + openEdit(row)}> + 编辑 + +
+
+ +
+ + 共 {total} 条,第 {page} / {totalPages} 页 + +
+ setPage((p) => Math.max(1, p - 1))} + > + 上一页 + + = totalPages} + onClick={() => setPage((p) => p + 1)} + > + 下一页 + +
+
+
+ )} + + {editing && ( +
!saving && setEditing(null)} + > +
e.stopPropagation()} + > +

+ 编辑加价 · {editing.customer_id} +

+

+ 百分比仅对运费加价(上限 30%);固定金额为每档报价叠加固定金额。 +

+
+ + setMarkupType(e.target.value as MarkupType) + } + /> + {markupType === "percent" ? ( + setPercent(e.target.value)} + /> + ) : ( + setFixedAmount(e.target.value)} + /> + )} + setRemark(e.target.value)} + /> +
+
+ setEditing(null)}> + 取消 + + void handleSave()}> + 保存 + +
+
+
+ )} +
+ ); +} diff --git a/app/admin/queues/page.tsx b/app/admin/queues/page.tsx new file mode 100644 index 0000000..1031482 --- /dev/null +++ b/app/admin/queues/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AdminLayout } from "@/components/layout/admin-layout"; +import { PageHeader } from "@/components/layout/page-header"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { SecondaryButton } from "@/components/ui/primary-button"; +import { adminGetQueueStats } from "@/lib/frontend/api-client"; +import { useAuth } from "@/hooks/use-auth"; +import type { DataPageStatus, QueueStats } from "@/lib/frontend/types"; + +const BULL_BOARD_PATH = "/api/admin/queues"; + +export default function QueuesAdminPage() { + const { token } = useAuth(); + const [status, setStatus] = useState("loading"); + const [stats, setStats] = useState(null); + const [boardReady, setBoardReady] = useState(false); + + const load = useCallback(async () => { + if (!token) return; + setStatus("loading"); + const res = await adminGetQueueStats("", token); + if (res.code !== 0) { + setStatus("error"); + return; + } + setStats(res.data); + setStatus("success"); + }, [token]); + + useEffect(() => { + void load(); + const id = setInterval(() => void load(), 10_000); + return () => clearInterval(id); + }, [load]); + + useEffect(() => { + if (!token) { + setBoardReady(false); + return; + } + const encoded = encodeURIComponent(token); + document.cookie = `bull_board_auth=${encoded}; path=${BULL_BOARD_PATH}; max-age=3600; SameSite=Lax`; + setBoardReady(true); + return () => { + document.cookie = `bull_board_auth=; path=${BULL_BOARD_PATH}; max-age=0`; + setBoardReady(false); + }; + }, [token]); + + return ( + + + + {status === "loading" && } + {status === "error" && ( + void load()}>重试} + > + 加载队列统计失败 + + )} + + {status === "success" && stats && ( +
+ +

队列深度

+

{stats.depth}

+
+ +

等待中

+

{stats.waiting}

+
+ +

执行中

+

{stats.active}

+
+ +

延迟

+

0 ? "text-warning" : "" + }`} + > + {stats.delayed} +

+
+ +

失败

+

0 ? "text-error" : "" + }`} + > + {stats.failed} +

+
+
+ )} + + + {!boardReady && } + {boardReady && ( +