From a9ed2189895fa89c7760701a6d7d63622ab63d3f Mon Sep 17 00:00:00 2001
From: wucm667
Date: Wed, 16 Sep 2026 13:19:15 +0800
Subject: [PATCH] feat(redeem): paginate user redemption history
---
backend/go.mod | 28 ++--
backend/go.sum | 68 +++++-----
backend/internal/handler/redeem_handler.go | 36 ++++++
.../internal/handler/redeem_handler_test.go | 89 +++++++++++++
.../internal/repository/redeem_code_repo.go | 4 +-
.../redeem_code_repo_sort_integration_test.go | 49 +++++++
backend/internal/service/redeem_service.go | 5 +
frontend/src/api/__tests__/redeem.spec.ts | 20 +++
frontend/src/api/redeem.ts | 10 +-
frontend/src/i18n/locales/en/dashboard.ts | 1 +
frontend/src/i18n/locales/zh/dashboard.ts | 1 +
.../__tests__/ChannelMonitorView.grok.spec.ts | 2 +-
.../GroupsView.codexManifest.spec.ts | 2 +
frontend/src/views/user/RedeemView.vue | 46 ++++++-
.../views/user/__tests__/RedeemView.spec.ts | 122 +++++++++++++++++-
15 files changed, 422 insertions(+), 61 deletions(-)
create mode 100644 backend/internal/handler/redeem_handler_test.go
create mode 100644 frontend/src/api/__tests__/redeem.spec.ts
diff --git a/backend/go.mod b/backend/go.mod
index 3cd44df4d9..941f519d08 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -50,13 +50,13 @@ require (
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
- golang.org/x/crypto v0.53.0
+ golang.org/x/crypto v0.55.0
golang.org/x/image v0.41.0
- golang.org/x/mod v0.37.0
- golang.org/x/net v0.56.0
- golang.org/x/sync v0.21.0
- golang.org/x/term v0.44.0
- google.golang.org/grpc v1.82.1
+ golang.org/x/mod v0.38.0
+ golang.org/x/net v0.58.0
+ golang.org/x/sync v0.22.0
+ golang.org/x/term v0.45.0
+ google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -192,20 +192,20 @@ require (
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
- go.opentelemetry.io/otel v1.43.0 // indirect
- go.opentelemetry.io/otel/metric v1.43.0 // indirect
- go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/text v0.39.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.12.0 // indirect
- golang.org/x/tools v0.47.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
+ golang.org/x/tools v0.48.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index f639504856..fd84fbb59d 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -525,22 +525,22 @@ github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
-go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
-go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
-go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
-go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
-go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
-go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
-go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
-go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
-go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
-go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
@@ -574,8 +574,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
@@ -590,8 +590,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
-golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -613,8 +613,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -626,8 +626,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -660,8 +660,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -674,8 +674,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
-golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -686,8 +686,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
-golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -702,8 +702,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
-golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -714,16 +714,16 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
-google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
-google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
-google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
diff --git a/backend/internal/handler/redeem_handler.go b/backend/internal/handler/redeem_handler.go
index 1b63f4183f..5d919cdc4e 100644
--- a/backend/internal/handler/redeem_handler.go
+++ b/backend/internal/handler/redeem_handler.go
@@ -1,7 +1,10 @@
package handler
import (
+ "strconv"
+
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -68,6 +71,39 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) {
return
}
+ // Keep the legacy array response for clients that do not request pagination.
+ if c.Request.URL.Query().Has("page") || c.Request.URL.Query().Has("page_size") {
+ page, pageSize := 1, 20
+ for name, target := range map[string]*int{"page": &page, "page_size": &pageSize} {
+ if raw, exists := c.GetQuery(name); exists {
+ value, err := strconv.Atoi(raw)
+ if err != nil || value < 1 {
+ response.BadRequest(c, "Invalid "+name)
+ return
+ }
+ *target = value
+ }
+ }
+ if pageSize > 100 {
+ pageSize = 100
+ }
+ if page-1 > int(^uint(0)>>1)/pageSize {
+ response.BadRequest(c, "Invalid page")
+ return
+ }
+ codes, result, err := h.redeemService.GetUserHistoryPaginated(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: page, PageSize: pageSize})
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ out := make([]dto.RedeemCode, 0, len(codes))
+ for i := range codes {
+ out = append(out, *dto.RedeemCodeFromService(&codes[i]))
+ }
+ response.Paginated(c, out, result.Total, page, pageSize)
+ return
+ }
+
// Default limit is 25
limit := 25
diff --git a/backend/internal/handler/redeem_handler_test.go b/backend/internal/handler/redeem_handler_test.go
new file mode 100644
index 0000000000..af84d690fc
--- /dev/null
+++ b/backend/internal/handler/redeem_handler_test.go
@@ -0,0 +1,89 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type historyRepo struct {
+ service.RedeemCodeRepository
+ userID int64
+ params pagination.PaginationParams
+ legacyLimit int
+}
+
+func (r *historyRepo) ListByUser(_ context.Context, userID int64, limit int) ([]service.RedeemCode, error) {
+ r.userID, r.legacyLimit = userID, limit
+ return []service.RedeemCode{}, nil
+}
+func (r *historyRepo) ListByUserPaginated(_ context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
+ r.userID, r.params = userID, params
+ return []service.RedeemCode{}, &pagination.PaginationResult{Total: 101}, nil
+}
+func TestRedeemHistory(t *testing.T) {
+ for _, tt := range []struct {
+ name, query string
+ userID int64
+ status, page, size int
+ }{
+ {"legacy", "", 7, 200, 0, 0},
+ {"default", "?page=1", 7, 200, 1, 20},
+ {"size only", "?page_size=50", 7, 200, 1, 50},
+ {"second user", "?page=2&page_size=100&user_id=7", 8, 200, 2, 100},
+ {"cap", "?page_size=101", 7, 200, 1, 100},
+ {"beyond last", "?page=100&page_size=20", 7, 200, 100, 20},
+ {"zero", "?page=0", 7, 400, 0, 0},
+ {"negative", "?page_size=-1", 7, 400, 0, 0},
+ {"invalid", "?page=abc", 7, 400, 0, 0},
+ {"overflow", "?page=9223372036854775807", 7, 400, 0, 0},
+ {"unauthenticated", "?page=1", 0, 401, 0, 0},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &historyRepo{}
+ h := NewRedeemHandler(service.NewRedeemService(repo, nil, nil, nil, nil, nil, nil, nil))
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest("GET", "/api/v1/redeem/history"+tt.query, nil)
+ if tt.userID != 0 {
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: tt.userID})
+ }
+ h.GetHistory(c)
+ require.Equal(t, tt.status, w.Code)
+ if tt.status != 200 {
+ require.Zero(t, repo.userID)
+ return
+ }
+ require.Equal(t, tt.userID, repo.userID)
+ var body struct {
+ Data json.RawMessage `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ if tt.query == "" {
+ require.Equal(t, 25, repo.legacyLimit)
+ require.JSONEq(t, "[]", string(body.Data))
+ return
+ }
+ require.Equal(t, tt.page, repo.params.Page)
+ require.Equal(t, tt.size, repo.params.PageSize)
+ var data struct {
+ Items []service.RedeemCode `json:"items"`
+ Total int `json:"total"`
+ Page int `json:"page"`
+ Size int `json:"page_size"`
+ }
+ require.NoError(t, json.Unmarshal(body.Data, &data))
+ require.NotNil(t, data.Items)
+ require.Equal(t, 101, data.Total)
+ require.Equal(t, tt.page, data.Page)
+ require.Equal(t, tt.size, data.Size)
+ })
+ }
+}
diff --git a/backend/internal/repository/redeem_code_repo.go b/backend/internal/repository/redeem_code_repo.go
index 2bdb34b42f..ef2841705d 100644
--- a/backend/internal/repository/redeem_code_repo.go
+++ b/backend/internal/repository/redeem_code_repo.go
@@ -347,7 +347,7 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
codes, err := r.client.RedeemCode.Query().
Where(redeemcode.UsedByEQ(userID)).
WithGroup().
- Order(dbent.Desc(redeemcode.FieldUsedAt)).
+ Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
Limit(limit).
All(ctx)
if err != nil {
@@ -377,7 +377,7 @@ func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID i
WithGroup().
Offset(params.Offset()).
Limit(params.Limit()).
- Order(dbent.Desc(redeemcode.FieldUsedAt)).
+ Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
All(ctx)
if err != nil {
return nil, nil, err
diff --git a/backend/internal/repository/redeem_code_repo_sort_integration_test.go b/backend/internal/repository/redeem_code_repo_sort_integration_test.go
index 30d32f4cf9..3b5b73e5d7 100644
--- a/backend/internal/repository/redeem_code_repo_sort_integration_test.go
+++ b/backend/internal/repository/redeem_code_repo_sort_integration_test.go
@@ -3,8 +3,10 @@
package repository
import (
+ "fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
+ "time"
)
func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
@@ -22,3 +24,50 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
s.Require().Equal("VALUE-10", codes[0].Code)
s.Require().Equal("VALUE-20", codes[1].Code)
}
+
+func (s *RedeemCodeRepoSuite) TestHistoryPaginationIsolationAndOrdering() {
+ user := s.createUser("history@example.com")
+ other := s.createUser("other-history@example.com")
+ now := time.Now().UTC().Truncate(time.Second)
+ var ids []int64
+ types := []string{service.RedeemTypeBalance, service.RedeemTypeConcurrency, service.RedeemTypeSubscription, "admin_balance", "admin_concurrency"}
+ for i := 0; i < 105; i++ {
+ usedAt := now
+ if i == 0 {
+ usedAt = now.Add(time.Hour)
+ }
+ code, err := s.client.RedeemCode.Create().SetCode(fmt.Sprintf("HISTORY-%d", i)).SetType(types[i%len(types)]).SetStatus(service.StatusUsed).SetUsedBy(user.ID).SetUsedAt(usedAt).Save(s.ctx)
+ s.Require().NoError(err)
+ ids = append(ids, code.ID)
+ }
+ _, err := s.client.RedeemCode.Create().SetCode("OTHER-HISTORY").SetType(service.RedeemTypeSubscription).SetStatus(service.StatusUsed).SetUsedBy(other.ID).SetUsedAt(now.Add(2 * time.Hour)).Save(s.ctx)
+ s.Require().NoError(err)
+ expected := []int64{ids[0]}
+ for i := len(ids) - 1; i > 0; i-- {
+ expected = append(expected, ids[i])
+ }
+ for _, size := range []int{20, 50, 100} {
+ var got []int64
+ for page := 1; page <= (105+size-1)/size+1; page++ {
+ codes, result, err := s.repo.ListByUserPaginated(s.ctx, user.ID, pagination.PaginationParams{Page: page, PageSize: size}, "")
+ s.Require().NoError(err)
+ s.Equal(int64(105), result.Total)
+ s.LessOrEqual(len(codes), size)
+ for _, code := range codes {
+ s.Equal(user.ID, *code.UsedBy)
+ got = append(got, code.ID)
+ }
+ }
+ s.Equal(expected, got)
+ }
+ codes, result, err := s.repo.ListByUserPaginated(s.ctx, other.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
+ s.Require().NoError(err)
+ s.Equal(int64(1), result.Total)
+ s.Require().Len(codes, 1)
+ s.Equal("OTHER-HISTORY", codes[0].Code)
+ empty := s.createUser("empty-history@example.com")
+ codes, result, err = s.repo.ListByUserPaginated(s.ctx, empty.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
+ s.Require().NoError(err)
+ s.Empty(codes)
+ s.Zero(result.Total)
+}
diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go
index 5e7cdee685..7b42b04d70 100644
--- a/backend/internal/service/redeem_service.go
+++ b/backend/internal/service/redeem_service.go
@@ -742,3 +742,8 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID,
return nil
}
+
+// GetUserHistoryPaginated returns all redemption types for the authenticated user.
+func (s *RedeemService) GetUserHistoryPaginated(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) {
+ return s.redeemRepo.ListByUserPaginated(ctx, userID, params, "")
+}
diff --git a/frontend/src/api/__tests__/redeem.spec.ts b/frontend/src/api/__tests__/redeem.spec.ts
new file mode 100644
index 0000000000..8c4284e9f5
--- /dev/null
+++ b/frontend/src/api/__tests__/redeem.spec.ts
@@ -0,0 +1,20 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getHistory } from '../redeem'
+
+const { get } = vi.hoisted(() => ({ get: vi.fn() }))
+vi.mock('../client', () => ({ apiClient: { get } }))
+
+describe('redemption history pagination', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it.each([[undefined, undefined, 1, 20], [3, 50, 3, 50], [2, 100, 2, 100]])(
+ 'sends page %s and size %s to the server', async (page, size, expectedPage, expectedSize) => {
+ const response = { items: [], total: 105, page: expectedPage, page_size: expectedSize, pages: 6 }
+ get.mockResolvedValue({ data: response })
+ expect(await getHistory(page, size)).toEqual(response)
+ expect(get).toHaveBeenCalledWith('/redeem/history', {
+ params: { page: expectedPage, page_size: expectedSize }
+ })
+ }
+ )
+})
diff --git a/frontend/src/api/redeem.ts b/frontend/src/api/redeem.ts
index 22abf4d80c..4a2eb3a544 100644
--- a/frontend/src/api/redeem.ts
+++ b/frontend/src/api/redeem.ts
@@ -4,7 +4,7 @@
*/
import { apiClient } from './client'
-import type { RedeemCodeRequest } from '@/types'
+import type { PaginatedResponse, RedeemCodeRequest } from '@/types'
export interface RedeemHistoryItem {
id: number
@@ -52,10 +52,12 @@ export async function redeem(code: string): Promise<{
/**
* Get user's redemption history
- * @returns List of redeemed codes
+ * @returns The requested page of redeemed codes and the total count
*/
-export async function getHistory(): Promise {
- const { data } = await apiClient.get('/redeem/history')
+export async function getHistory(page = 1, pageSize = 20): Promise> {
+ const { data } = await apiClient.get>('/redeem/history', {
+ params: { page, page_size: pageSize }
+ })
return data
}
diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts
index dc8d206fa3..76be95b4d2 100644
--- a/frontend/src/i18n/locales/en/dashboard.ts
+++ b/frontend/src/i18n/locales/en/dashboard.ts
@@ -784,6 +784,7 @@ export default {
days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
+ historyLoadFailed: 'Failed to load activity. Please try again.',
userRefreshFailed: 'Redeemed successfully, but failed to refresh account information.',
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.',
pleaseEnterCode: 'Please enter a redeem code'
diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts
index 5e1cb30bd6..d74ff78d99 100644
--- a/frontend/src/i18n/locales/zh/dashboard.ts
+++ b/frontend/src/i18n/locales/zh/dashboard.ts
@@ -788,6 +788,7 @@ export default {
days: '天',
codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。',
+ historyLoadFailed: '加载兑换记录失败,请重试。',
userRefreshFailed: '兑换成功,但账户信息刷新失败。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。',
pleaseEnterCode: '请输入兑换码'
diff --git a/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts b/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts
index 007876a72f..5b581811d7 100644
--- a/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts
+++ b/frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts
@@ -101,7 +101,7 @@ describe('channel monitor Grok provider', () => {
expect(PROVIDERS).toContain(PROVIDER_GROK)
const providerButtons = wrapper.findAll('[data-testid^="monitor-provider-"]')
- expect(providerButtons).toHaveLength(8)
+ expect(providerButtons).toHaveLength(10)
expect(providerButtons[0].element.parentElement?.className).toContain('grid-cols-2')
expect(providerButtons[0].element.parentElement?.className).toContain('sm:grid-cols-4')
diff --git a/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts
index 9595d27ed3..b7750268f0 100644
--- a/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts
+++ b/frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts
@@ -1,5 +1,6 @@
import { defineComponent, h, type PropType } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
+import { createPinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AdminGroup, CodexModelsManifestConfig } from "@/types";
@@ -204,6 +205,7 @@ const CodexManifestAccountsFieldStub = defineComponent({
const mountView = () =>
mount(GroupsView, {
global: {
+ plugins: [createPinia()],
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
diff --git a/frontend/src/views/user/RedeemView.vue b/frontend/src/views/user/RedeemView.vue
index 32a16f2f94..0e9bcc3b0c 100644
--- a/frontend/src/views/user/RedeemView.vue
+++ b/frontend/src/views/user/RedeemView.vue
@@ -335,6 +335,30 @@
{{ t('redeem.historyWillAppear') }}
+
+ {{ t('common.total') }}: {{ historyTotal }} {{ t('pagination.results') }}
+
+
+
+
@@ -375,6 +399,11 @@ const errorMessage = ref('')
// History data
const history = ref([])
const loadingHistory = ref(false)
+const historyPage = ref(1)
+const historyPageSize = ref(20)
+const historyTotal = ref(0)
+let historyRequest = 0
+let loadedHistoryPageSize = 20
const contactInfo = ref('')
// Helper functions for history display
@@ -420,14 +449,25 @@ const formatHistoryValue = (item: RedeemHistoryItem) => {
}
}
-const fetchHistory = async () => {
+const fetchHistory = async (page = 1) => {
+ const request = ++historyRequest
+ const pageSize = historyPageSize.value
loadingHistory.value = true
try {
- history.value = await redeemAPI.getHistory()
+ const result = await redeemAPI.getHistory(page, pageSize)
+ if (request !== historyRequest) return
+ history.value = result.items
+ historyTotal.value = result.total
+ historyPage.value = page
+ historyPageSize.value = pageSize
+ loadedHistoryPageSize = pageSize
} catch (error) {
+ if (request !== historyRequest) return
+ historyPageSize.value = loadedHistoryPageSize
+ appStore.showError(t('redeem.historyLoadFailed'))
console.error('Failed to fetch history:', error)
} finally {
- loadingHistory.value = false
+ if (request === historyRequest) loadingHistory.value = false
}
}
diff --git a/frontend/src/views/user/__tests__/RedeemView.spec.ts b/frontend/src/views/user/__tests__/RedeemView.spec.ts
index 7c71092fca..85bfc9e6da 100644
--- a/frontend/src/views/user/__tests__/RedeemView.spec.ts
+++ b/frontend/src/views/user/__tests__/RedeemView.spec.ts
@@ -45,7 +45,7 @@ describe('RedeemView refresh after redemption', () => {
beforeEach(() => {
vi.clearAllMocks()
redeem.mockResolvedValue({ type: 'balance', value: 20, message: 'Code applied' })
- getHistory.mockResolvedValue([])
+ getHistory.mockResolvedValue({ items: [], total: 0 })
refreshUser.mockResolvedValue({ balance: 30, concurrency: 2 })
fetchActiveSubscriptions.mockResolvedValue([])
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -59,9 +59,9 @@ describe('RedeemView refresh after redemption', () => {
'keeps a successful %s redemption when profile refresh fails', async (type) => {
redeem.mockResolvedValue({ type, value: 20, message: 'Code applied' })
refreshUser.mockRejectedValue({ status: 503, message: 'Service unavailable' })
- getHistory.mockResolvedValueOnce([]).mockResolvedValueOnce([{
+ getHistory.mockResolvedValueOnce({ items: [], total: 0 }).mockResolvedValueOnce({ total: 1, items: [{
id: 1, code: 'REDEEM-CODE', type, value: 20, used_at: '2026-03-08T00:00:00Z',
- }])
+ }] })
const wrapper = await submitCode()
@@ -84,6 +84,122 @@ describe('RedeemView refresh after redemption', () => {
}
)
+ it('pages on the server, changes size, and resets page and total after redeeming', async () => {
+ getHistory.mockResolvedValue({ items: [], total: 101 })
+ const wrapper = mount(RedeemView, {
+ global: { stubs: { AppLayout: { template: '
' }, Icon: true } },
+ })
+ await flushPromises()
+ const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)!
+ expect(getHistory).toHaveBeenLastCalledWith(1, 20)
+ expect(button('pagination.previous').attributes('disabled')).toBeDefined()
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(2, 20)
+ await button('pagination.previous').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 20)
+ expect(wrapper.findAll('select option').map(o => o.text())).toEqual(['20', '50', '100'])
+ await wrapper.get('select').setValue('50')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ await wrapper.get('select').setValue('100')
+ await flushPromises()
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(2, 100)
+ expect(button('pagination.next').attributes('disabled')).toBeDefined()
+ getHistory.mockResolvedValue({ items: [], total: 102 })
+ await wrapper.get('input#code').setValue('NEW-CODE')
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 100)
+ expect(wrapper.text()).toContain('102')
+ expect(button('pagination.previous').attributes('disabled')).toBeDefined()
+ wrapper.unmount()
+ })
+
+ it('restores the loaded size and keeps rows and navigation usable after a size request fails', async () => {
+ const item = { id: 1, code: 'OLD-ROWS', type: 'balance', value: 20, used_at: '2026-03-08T00:00:00Z' }
+ getHistory.mockResolvedValue({ items: [item], total: 61 })
+ const wrapper = mount(RedeemView, {
+ global: { stubs: { AppLayout: { template: '
' }, Icon: true } },
+ })
+ await flushPromises()
+ const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)!
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ let rejectRequest!: (error: Error) => void
+ getHistory.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectRequest = reject }))
+ await wrapper.get('select').setValue('50')
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ expect(button('pagination.next').attributes('disabled')).toBeDefined()
+ rejectRequest(new Error('Network error'))
+ await flushPromises()
+ expect(wrapper.get('select').element.value).toBe('20')
+ expect(wrapper.text()).toContain('OLD-ROWS')
+ expect(wrapper.text()).toContain('61')
+ expect(button('pagination.previous').attributes('disabled')).toBeUndefined()
+ expect(button('pagination.next').attributes('disabled')).toBeUndefined()
+ expect(showError).toHaveBeenCalledWith('redeem.historyLoadFailed')
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(3, 20)
+ await wrapper.get('select').setValue('50')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ expect(wrapper.get('select').element.value).toBe('50')
+ expect(button('pagination.previous').attributes('disabled')).toBeDefined()
+ wrapper.unmount()
+ })
+
+ it.each(['success', 'failure'])('ignores a stale history %s after a newer size request succeeds', async (outcome) => {
+ let resolveOld!: (value: unknown) => void
+ let rejectOld!: (error: Error) => void
+ getHistory.mockImplementationOnce(() => new Promise((resolve, reject) => {
+ resolveOld = resolve
+ rejectOld = reject
+ }))
+ const wrapper = mount(RedeemView, {
+ global: { stubs: { AppLayout: { template: '
' }, Icon: true } },
+ })
+ getHistory.mockResolvedValue({ items: [{
+ id: 2, code: 'NEW-ROWS', type: 'balance', value: 30, used_at: '2026-03-08T00:00:00Z',
+ }], total: 61 })
+ // Force overlapping requests to exercise responses arriving out of order.
+ await wrapper.get('select').setValue('50')
+ await flushPromises()
+ const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)!
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(2, 50)
+ if (outcome === 'success') {
+ resolveOld({ items: [], total: 0 })
+ } else {
+ rejectOld(new Error('Stale network error'))
+ }
+ await flushPromises()
+ expect(wrapper.get('select').element.value).toBe('50')
+ expect(wrapper.text()).toContain('NEW-ROWS')
+ expect(wrapper.text()).toContain('61')
+ expect(button('pagination.previous').attributes('disabled')).toBeUndefined()
+ expect(button('pagination.next').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('select').attributes('disabled')).toBeUndefined()
+ expect(showError).not.toHaveBeenCalled()
+ await button('pagination.previous').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ wrapper.unmount()
+ })
+
+ it('disables both paging buttons for empty history', async () => {
+ const wrapper = await submitCode()
+ for (const button of wrapper.findAll('button').filter(b => b.text().startsWith('pagination.'))) {
+ expect(button.attributes('disabled')).toBeDefined()
+ }
+ wrapper.unmount()
+ })
+
it('finishes normally without a warning when profile refresh succeeds', async () => {
const wrapper = await submitCode()