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()