From bb3990e432a206bcf33deee1a65b2f58f19378da Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 5 Oct 2026 16:33:38 +0800 Subject: [PATCH] feat(#356): support pasted purchase order filters --- .../purchase/admin_order_filter_test.go | 153 ++++++++++++++++++ server/app/goauto/purchase/admin_query.go | 65 ++++++-- server/app/goauto/purchase/handler.go | 7 +- web/src/api/goauto/purchase-tasks.js | 20 ++- web/src/utils/purchase-order-filter.js | 10 ++ web/src/views/goauto/purchase-tasks/index.vue | 27 +++- web/tests/e2e/purchase-order-filter.spec.ts | 118 ++++++++++++++ web/tests/unit/purchase-order-filter.test.cjs | 42 +++++ 8 files changed, 425 insertions(+), 17 deletions(-) create mode 100644 server/app/goauto/purchase/admin_order_filter_test.go create mode 100644 web/src/utils/purchase-order-filter.js create mode 100644 web/tests/e2e/purchase-order-filter.spec.ts create mode 100644 web/tests/unit/purchase-order-filter.test.cjs diff --git a/server/app/goauto/purchase/admin_order_filter_test.go b/server/app/goauto/purchase/admin_order_filter_test.go new file mode 100644 index 0000000..fce0903 --- /dev/null +++ b/server/app/goauto/purchase/admin_order_filter_test.go @@ -0,0 +1,153 @@ +package purchase + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/google/uuid" + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" +) + +func TestNormalizeAdminOrderNos(t *testing.T) { + hundred := make([]string, 100) + for i := range hundred { + hundred[i] = fmt.Sprintf("DEMO-%03d", i) + } + for _, tc := range []struct { + name, single string + raw, want []string + invalid bool + }{ + {name: "absent"}, + {name: "legacy single", single: "PARTIAL"}, + {name: "mixed and duplicate", raw: []string{" 001\r\nDEMO-A,DEMO-B\t001", "DEMO-A,DEMO-C\u0085\uFEFF"}, want: []string{"001", "DEMO-A", "DEMO-B", "DEMO-C"}}, + {name: "100", raw: hundred, want: hundred}, + {name: "duplicates do not count", raw: append(append([]string{}, hundred...), hundred...), want: hundred}, + {name: "101", raw: append(append([]string{}, hundred...), "EXTRA"), invalid: true}, + {name: "64 runes", raw: []string{strings.Repeat("中", 64)}, want: []string{strings.Repeat("中", 64)}}, + {name: "65 runes", raw: []string{"GOOD", strings.Repeat("中", 65)}, invalid: true}, + {name: "single too long", single: strings.Repeat("A", 65), invalid: true}, + {name: "empty collection", raw: []string{}, invalid: true}, + {name: "blank collection", raw: []string{" ,,\r\n\t"}, invalid: true}, + {name: "conflicting parameters", single: "PART", raw: []string{"DEMO-A"}, invalid: true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizeAdminOrderNos(tc.single, tc.raw) + if tc.invalid { + if code(err) != CodeInvalidRequest { + t.Fatalf("expected invalid request, got %v", err) + } + return + } + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %v, %v; want %v", got, err, tc.want) + } + }) + } +} + +func TestAdminOrderNosExactFilterAndPagination(t *testing.T) { + db := testDB(t) + f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true) + s := testService(db) + base := createAdminQueryTask(t, s, f) + // Isolated fixture records only; no device execution or external order creation. + for i, orderNo := range []string{"001", "DEMO-A", "DEMO-A", "XDEMO-A", "DEMO-A2", "DEMO-B", "' OR 1=1 --"} { + row := base + row.ID = 0 + row.CreateRequestID = uuid.NewString() + row.ShopeeOrderNoSnapshot = orderNo + row.ShopeeTitleSnapshot = "DEMO-A" + row.Status = models.PurchaseTaskStatusFailed + if i == 5 { + row.Status = models.PurchaseTaskStatusCancelled + } + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + } + req := AdminListRequest{Page: 1, PageSize: 2, Status: models.PurchaseTaskStatusFailed, ShopeeOrderNos: []string{"001", "DEMO-A", "DEMO-B"}} + first, err := s.AdminList(context.Background(), req) + if err != nil || first.Total != 3 || len(first.Items) != 2 { + t.Fatalf("first: %+v %v", first, err) + } + req.Page = 2 + second, err := s.AdminList(context.Background(), req) + if err != nil || second.Total != 3 || len(second.Items) != 1 { + t.Fatalf("second: %+v %v", second, err) + } + seen := map[uint64]bool{} + for _, row := range append(first.Items, second.Items...) { + if seen[row.ID] || (row.ShopeeOrderNoSnapshot != "001" && row.ShopeeOrderNoSnapshot != "DEMO-A") { + t.Fatalf("unexpected row %+v", row) + } + seen[row.ID] = true + } + for _, query := range [][]string{{"MISSING", "DEMO"}, {"%", "_"}, {"' OR 1=1 --", "MISSING"}} { + out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: query}) + if err != nil || out.Total != 0 { + t.Fatalf("unexpected broadened query %v: %+v %v", query, out, err) + } + } + // A repeated-key collection remains exact even with one item; old scalar stays LIKE. + out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: []string{"DEMO-A"}}) + if err != nil || out.Total != 2 { + t.Fatalf("one-item collection: %+v %v", out, err) + } + out, err = s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNo: "DEMO-A"}) + if err != nil || out.Total != 4 { + t.Fatalf("legacy LIKE: %+v %v", out, err) + } +} + +func TestAdminOrderNosHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + db := testDB(t) + f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true) + s := testService(db) + row := createAdminQueryTask(t, s, f) + if err := db.Model(&row).Update("shopee_order_no_snapshot", "DEMO-A").Error; err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + query string + invalid bool + }{ + {"shopeeOrderNos=DEMO-A&shopeeOrderNos=DEMO-B", false}, + {"shopeeOrderNos=DEMO-A%2CDEMO-B", false}, + {"shopeeOrderNo=DEMO", false}, + {"shopeeOrderNos=", true}, + {"shopeeOrderNos=DEMO-A&shopeeOrderNo=DEMO", true}, + {"shopeeOrderNos=" + url.QueryEscape(strings.Repeat("A", 65)), true}, + } { + for _, role := range []string{"admin", "purchaser", "after_sales"} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role}) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+tc.query, nil) + (Handler{DB: db}).AdminList(c) + if tc.invalid { + if !strings.Contains(w.Body.String(), CodeInvalidRequest) { + t.Fatalf("invalid query accepted: %s", w.Body.String()) + } + continue + } + var result struct { + Data AdminListResponse `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK || result.Data.Total != 1 { + t.Fatalf("query %s: %d %s", tc.query, w.Code, w.Body.String()) + } + } + } +} diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index 444d414..cf2af1f 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -6,6 +6,8 @@ import ( "errors" "strings" "time" + "unicode" + "unicode/utf8" "go-admin/app/goauto/models" @@ -13,15 +15,16 @@ import ( ) type AdminListRequest struct { - Page int - PageSize int - TaskID uint64 - Status string - ExecutionMode string - TaskType string - SYBProductID uint64 - ShopeeOrderNo string - PDDOrderNo string + Page int + PageSize int + TaskID uint64 + Status string + ExecutionMode string + TaskType string + SYBProductID uint64 + ShopeeOrderNo string + ShopeeOrderNos []string + PDDOrderNo string } type AdminTaskItem struct { @@ -112,7 +115,48 @@ type AdminDetailResponse struct { // Query-only value: never a PurchaseTask status or a logistics writeback state. const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded" +// A nil list means the optional filter was absent; an explicitly empty list +// must not silently turn a restricted lookup into an unfiltered query. +func normalizeAdminOrderNos(single string, raw []string) ([]string, error) { + single = strings.TrimSpace(single) + if utf8.RuneCountInString(single) > 64 { + return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符") + } + if raw == nil { + return nil, nil + } + if single != "" { + return nil, fail(CodeInvalidRequest, "蝦皮订单号单值与批量参数不能同时使用") + } + values := make([]string, 0, len(raw)) + seen := make(map[string]bool) + for _, entry := range raw { + for _, value := range strings.FieldsFunc(entry, func(r rune) bool { + return r == ',' || r == ',' || unicode.IsSpace(r) || r == '\uFEFF' + }) { + if utf8.RuneCountInString(value) > 64 { + return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符") + } + if !seen[value] { + seen[value] = true + values = append(values, value) + if len(values) > 100 { + return nil, fail(CodeInvalidRequest, "最多查询 100 个蝦皮订单号,请分批查询") + } + } + } + } + if len(values) == 0 { + return nil, fail(CodeInvalidRequest, "批量蝦皮订单号不能为空") + } + return values, nil +} + func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) { + orderNos, err := normalizeAdminOrderNos(req.ShopeeOrderNo, req.ShopeeOrderNos) + if err != nil { + return AdminListResponse{}, err + } if req.Page < 1 { req.Page = 1 } @@ -152,6 +196,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" { query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%") } + if len(orderNos) > 0 { + query = query.Where("shopee_order_no_snapshot IN ?", orderNos) + } if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" { query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%") } diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 51aa077..d10b0eb 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -52,9 +52,10 @@ func (h Handler) AdminList(c *gin.Context) { response, err := service.AdminList(c.Request.Context(), AdminListRequest{ Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID, Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")), - TaskType: strings.TrimSpace(c.Query("taskType")), - ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")), - PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")), + TaskType: strings.TrimSpace(c.Query("taskType")), + ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")), + ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"], + PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")), }) if err != nil { writeError(c, err) diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js index dcd91d8..6bc5fc3 100644 --- a/web/src/api/goauto/purchase-tasks.js +++ b/web/src/api/goauto/purchase-tasks.js @@ -1,6 +1,24 @@ import request from '@/utils/request' -export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) } +export function listPurchaseTasks(params) { + return request({ + url: '/api/admin/v1/purchase-tasks', method: 'get', params, + paramsSerializer: { + serialize(values) { + const query = new URLSearchParams() + Object.entries(values).forEach(([key, value]) => { + if (value == null) return + if (key === 'shopeeOrderNos' && Array.isArray(value)) { + // Preserve an explicitly empty list so the server rejects it. + if (!value.length) query.append(key, '') + value.forEach(item => query.append(key, item)) + } else query.append(key, value) + }) + return query.toString() + } + } + }) +} export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) } export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) } export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) } diff --git a/web/src/utils/purchase-order-filter.js b/web/src/utils/purchase-order-filter.js new file mode 100644 index 0000000..50aaf4a --- /dev/null +++ b/web/src/utils/purchase-order-filter.js @@ -0,0 +1,10 @@ +// Match the Server's whitespace/comma splitting; keep identifiers as strings. +export function parsePurchaseOrderFilter(raw = '') { + const values = [...new Set(raw.split(/[,,\s\u0085]+/u).filter(Boolean))] + const error = values.length > 100 + ? '最多查询 100 个蝦皮订单号,请分批查询' + : values.some(value => [...value].length > 64) + ? '每个蝦皮订单号最多 64 个字符' + : '' + return { values, error } +} diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index 41088b8..f8a9a4c 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -6,11 +6,17 @@ - + +
+ +
已输入 {{ orderFilter.values.length }} 个订单号。
单个支持包含查询,多个按完整订单号匹配。
+ +
+
- 查询重置 + 查询重置
已选择 {{ retrySelection.length }} 条重试({{ retryCandidates.length }})批量回填 SYB({{ orderWritebackCandidates.length }})
@@ -142,6 +148,7 @@ diff --git a/web/tests/e2e/purchase-order-filter.spec.ts b/web/tests/e2e/purchase-order-filter.spec.ts new file mode 100644 index 0000000..3409f1d --- /dev/null +++ b/web/tests/e2e/purchase-order-filter.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test' + +async function mockList(page: any) { + const state = { queries: [] as URLSearchParams[], fail: false, empty: false, writes: 0 } + await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }]) + await page.route('**/api/**', async route => { + const req = route.request() + const url = new URL(req.url()) + if (url.pathname.startsWith('/src/api/')) return route.continue() + if (req.method() !== 'GET') { state.writes++; return route.abort() } + if (url.pathname.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '测试采购员', avatar: '', introduction: '', permissions: [] } } }) + if (url.pathname.endsWith('/menurole')) return route.fulfill({ json: { code: 200, data: [{ path: '/collection-purchase', component: 'Layout', menuName: 'Purchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'PurchaseTasks', title: '采购管理', visible: '0' }] }] } }) + if (url.pathname.endsWith('/purchase-tasks')) { + state.queries.push(url.searchParams) + if (state.fail) return route.fulfill({ status: 500, json: { code: 500, message: '模拟查询失败' } }) + return route.fulfill({ json: { code: 200, data: { items: state.empty ? [] : [{ id: 101, taskType: 'syb_order', status: 'failed', executionMode: 'live', retryable: true, shopeeOrderNoSnapshot: 'DEMO-A', quantity: 1, createdAt: '2026-10-01T00:00:00Z' }], total: state.empty ? 0 : 21 } } }) + } + return route.fulfill({ json: { code: 200, data: [] } }) + }) + await page.goto('/#/purchase-tasks/index') + await expect(page.getByText('CG-101', { exact: true })).toBeVisible() + return state +} + +test('批量粘贴去重、精确集合与状态组合,翻页保留条件并复用重试确认', async ({ page }) => { + const state = await mockList(page) + const field = page.getByLabel('蝦皮订单号', { exact: true }) + await field.fill('001\r\nDEMO-A,DEMO-B\t001 DEMO-A') + await expect(page.getByText('已输入 3 个订单号。')).toBeVisible() + const before = state.queries.length + await field.press('End') + await field.press('Enter') + await expect(field).toHaveValue(/\n$/) + expect(state.queries.length).toBe(before) + await page.locator('.el-form-item').filter({ has: page.locator('label').filter({ hasText: /^状态$/ }) }).locator('.el-select').click() + await page.getByRole('option', { name: '采购失败', exact: true }).click() + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect.poll(() => state.queries.at(-1)?.getAll('shopeeOrderNos')).toEqual(['001', 'DEMO-A', 'DEMO-B']) + expect(state.queries.at(-1)?.get('shopeeOrderNo')).toBeNull() + expect(state.queries.at(-1)?.get('status')).toBe('failed') + await page.locator('.btn-next').click() + await expect.poll(() => state.queries.at(-1)?.get('page')).toBe('2') + expect(state.queries.at(-1)?.getAll('shopeeOrderNos')).toEqual(['001', 'DEMO-A', 'DEMO-B']) + await page.locator('.el-table__body .el-checkbox').first().click() + await page.getByRole('button', { name: '重试(1)', exact: true }).click() + await expect(page.getByRole('button', { name: '确认重试', exact: true })).toBeVisible() + expect(state.writes).toBe(0) +}) + +test('单号兼容、清空重置及空列表', async ({ page }) => { + const state = await mockList(page) + const field = page.getByLabel('蝦皮订单号', { exact: true }) + await field.fill(' DEMO-A\nDEMO-A ') + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect.poll(() => state.queries.at(-1)?.get('shopeeOrderNo')).toBe('DEMO-A') + expect(state.queries.at(-1)?.has('shopeeOrderNos')).toBe(false) + await field.fill('') + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect.poll(() => state.queries.at(-1)?.has('shopeeOrderNo')).toBe(false) + await field.fill('MISSING-A,MISSING-B') + state.empty = true + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect(page.getByText('暂无采购任务')).toBeVisible() + await expect(field).toHaveValue('MISSING-A,MISSING-B') + await page.getByRole('button', { name: '重置', exact: true }).click() + await expect(field).toHaveValue('') + await expect.poll(() => state.queries.at(-1)?.has('shopeeOrderNos')).toBe(false) + expect(state.writes).toBe(0) +}) + +test('100个可查、101个或过长就地报错且不请求', async ({ page }) => { + const state = await mockList(page) + const field = page.getByLabel('蝦皮订单号', { exact: true }) + const hundred = Array.from({ length: 100 }, (_, i) => `DEMO-${i}`) + await field.fill(hundred.join('\n')) + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect.poll(() => state.queries.at(-1)?.getAll('shopeeOrderNos').length).toBe(100) + const before = state.queries.length + await field.fill([...hundred, 'EXTRA'].join('\n')) + await expect(page.getByRole('alert').filter({ hasText: '最多查询 100' })).toBeVisible() + await expect(page.getByRole('button', { name: '查询', exact: true })).toBeDisabled() + await field.fill('A'.repeat(65)) + await expect(page.getByRole('alert').filter({ hasText: '64' })).toBeVisible() + expect(state.queries.length).toBe(before) + expect(state.writes).toBe(0) +}) + +test('查询失败保留输入、清除旧勾选且不自动重试', async ({ page }) => { + const state = await mockList(page) + await page.locator('.el-table__body .el-checkbox').first().click() + await expect(page.getByRole('button', { name: '重试(1)', exact: true })).toBeEnabled() + const field = page.getByLabel('蝦皮订单号', { exact: true }) + await field.fill('DEMO-A DEMO-B') + state.fail = true + const before = state.queries.length + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect(page.getByRole('button', { name: '重新加载', exact: true })).toBeVisible() + await expect(field).toHaveValue('DEMO-A DEMO-B') + await expect(page.getByRole('button', { name: '重试(0)', exact: true })).toBeDisabled() + expect(state.queries.length).toBe(before + 1) + expect(state.writes).toBe(0) +}) + +test('轻量布局在桌面和窄屏可读,输入与查询可键盘访问', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await mockList(page) + for (const viewport of [{ width: 1440, height: 900 }, { width: 375, height: 812 }, { width: 812, height: 375 }]) { + await page.setViewportSize(viewport) + const field = page.getByLabel('蝦皮订单号', { exact: true }) + await field.focus() + await expect(field).toBeFocused() + await expect(field).toBeInViewport() + const box = await field.boundingBox() + expect(box!.width).toBeLessThanOrEqual(viewport.width) + expect(box!.height).toBeGreaterThan(40) + await expect(page.getByRole('button', { name: '查询', exact: true })).toBeVisible() + } +}) diff --git a/web/tests/unit/purchase-order-filter.test.cjs b/web/tests/unit/purchase-order-filter.test.cjs new file mode 100644 index 0000000..0843ea0 --- /dev/null +++ b/web/tests/unit/purchase-order-filter.test.cjs @@ -0,0 +1,42 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const source = fs.readFileSync(path.join(__dirname, '../../src/utils/purchase-order-filter.js'), 'utf8') +const parse = new Function(source.replace('export function', 'function') + '\nreturn parsePurchaseOrderFilter')() +const apiSource = fs.readFileSync(path.join(__dirname, '../../src/api/goauto/purchase-tasks.js'), 'utf8') +const api = new Function('request', apiSource.replace(/^import .+\n/gm, '').replace(/export function /g, 'function ') + '\nreturn { listPurchaseTasks, retryPurchaseTasksBatch }')(config => config) + +test('split mixed delimiters, remove empty/duplicates, preserve identifiers', () => { + assert.deepEqual(parse(' 001\r\nDEMO-A,DEMO-B\t001,DEMO-C\u0085\uFEFF'), { values: ['001', 'DEMO-A', 'DEMO-B', 'DEMO-C'], error: '' }) + assert.deepEqual(parse(' ,,\t\r\n'), { values: [], error: '' }) + assert.deepEqual(parse('DEMO-A\nDEMO-A').values, ['DEMO-A']) +}) + +test('100 unique orders and 64 Unicode code points allowed, overflow rejected without truncation', () => { + const hundred = Array.from({ length: 100 }, (_, i) => `DEMO-${i}`) + assert.equal(parse(hundred.join('\n')).error, '') + assert.equal(parse([...hundred, ...hundred].join(',')).error, '') + assert.equal(parse([...hundred, 'EXTRA'].join(',')).values.length, 101) + assert.match(parse([...hundred, 'EXTRA'].join(',')).error, /100/) + assert.equal(parse('中'.repeat(64)).error, '') + assert.equal(parse('😀'.repeat(64)).error, '') + assert.match(parse('中'.repeat(65)).error, /64/) +}) + +test('API serializes repeated keys and special characters without changing other filters', () => { + const params = { shopeeOrderNos: ['001', 'A&B', 'C+D'], page: 2, status: 'failed', pddOrderNo: 'PDD-1' } + const config = api.listPurchaseTasks(params) + const query = new URLSearchParams(config.paramsSerializer.serialize(params)) + assert.deepEqual(query.getAll('shopeeOrderNos'), params.shopeeOrderNos) + assert.equal(query.get('shopeeOrderNos[]'), null) + assert.equal(query.get('page'), '2') + assert.equal(query.get('status'), 'failed') + assert.equal(query.get('pddOrderNo'), 'PDD-1') + assert.equal(config.timeout, undefined) + assert.equal(config.paramsSerializer.serialize({ shopeeOrderNos: [] }), 'shopeeOrderNos=') + assert.equal(config.paramsSerializer.serialize({ shopeeOrderNo: 'PART' }), 'shopeeOrderNo=PART') + assert.equal(api.retryPurchaseTasksBatch({ taskIds: [1] }).paramsSerializer, undefined) + assert.equal(api.retryPurchaseTasksBatch({ taskIds: [1] }).timeout, 60000) +})