mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
Merge pull request #7212 from wucm667/feat/issue-7203-redemption-pagination
feat(redeem): paginate user redemption history
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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<RedeemHistoryItem[]> {
|
||||
const { data } = await apiClient.get<RedeemHistoryItem[]>('/redeem/history')
|
||||
export async function getHistory(page = 1, pageSize = 20): Promise<PaginatedResponse<RedeemHistoryItem>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<RedeemHistoryItem>>('/redeem/history', {
|
||||
params: { page, page_size: pageSize }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -788,6 +788,7 @@ export default {
|
||||
days: '天',
|
||||
codeRedeemSuccess: '兑换成功!',
|
||||
failedToRedeem: '兑换失败,请检查兑换码后重试。',
|
||||
historyLoadFailed: '加载兑换记录失败,请重试。',
|
||||
userRefreshFailed: '兑换成功,但账户信息刷新失败。',
|
||||
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。',
|
||||
pleaseEnterCode: '请输入兑换码'
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -335,6 +335,30 @@
|
||||
{{ t('redeem.historyWillAppear') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<span>{{ t('common.total') }}: {{ historyTotal }} {{ t('pagination.results') }}</span>
|
||||
<label>
|
||||
{{ t('pagination.perPage') }}
|
||||
<select
|
||||
v-model="historyPageSize"
|
||||
class="input w-20"
|
||||
:disabled="loadingHistory || submitting"
|
||||
@change="fetchHistory(1)"
|
||||
>
|
||||
<option v-for="size in [20, 50, 100]" :key="size" :value="size">{{ size }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="loadingHistory || submitting || historyPage <= 1"
|
||||
@click="fetchHistory(historyPage - 1)"
|
||||
>{{ t('pagination.previous') }}</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="loadingHistory || submitting || historyPage * historyPageSize >= historyTotal"
|
||||
@click="fetchHistory(historyPage + 1)"
|
||||
>{{ t('pagination.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,6 +399,11 @@ const errorMessage = ref('')
|
||||
// History data
|
||||
const history = ref<RedeemHistoryItem[]>([])
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '<div><slot /></div>' }, 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: '<div><slot /></div>' }, 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: '<div><slot /></div>' }, 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user