mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
test(costs): the ledger cases wait for the settlement, and the filter and former member cases get files of their own
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// FE-W5COSTS-014 to FE-W5COSTS-019 and FE-W5COSTS-076: filtering the Costs ledger
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, within } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import CostsPanel from './CostsPanel';
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
|
||||
type Payment = {
|
||||
id: number;
|
||||
from_user_id: number;
|
||||
to_user_id: number;
|
||||
amount: number;
|
||||
created_at?: string;
|
||||
settled_at?: string | null;
|
||||
};
|
||||
type MemberFixture = { user_id: number; username?: string };
|
||||
const expense = (over: Partial<Omit<BudgetItem, 'members'>> & { members?: MemberFixture[] }): BudgetItem =>
|
||||
({ ...buildBudgetItem({ trip_id: 1 }), payers: [], members: [], ...over }) as unknown as BudgetItem;
|
||||
|
||||
function mount(items: BudgetItem[], settlement: { settlements?: Payment[] } = {}) {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({ balances: [], flows: [], settlements: [], ...settlement })
|
||||
)
|
||||
);
|
||||
return render(<CostsPanel tripId={1} tripMembers={tripMembers} />);
|
||||
}
|
||||
|
||||
// Alice is the signed-in user; the euro display currency keeps FX an identity.
|
||||
function seedAlice() {
|
||||
resetAllStores();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } });
|
||||
}
|
||||
|
||||
const dinner = () =>
|
||||
expense({
|
||||
id: 101,
|
||||
name: 'Dinner',
|
||||
category: 'food',
|
||||
total_price: 90,
|
||||
expense_date: '2025-06-15',
|
||||
payers: [{ user_id: 1, amount: 90 }],
|
||||
members: [
|
||||
{ user_id: 1, username: 'alice' },
|
||||
{ user_id: 2, username: 'bob' },
|
||||
],
|
||||
});
|
||||
const taxi = () =>
|
||||
expense({
|
||||
id: 102,
|
||||
name: 'Taxi',
|
||||
category: 'transport',
|
||||
total_price: 30,
|
||||
expense_date: '2025-06-16',
|
||||
payers: [{ user_id: 2, amount: 30 }],
|
||||
members: [
|
||||
{ user_id: 1, username: 'alice' },
|
||||
{ user_id: 2, username: 'bob' },
|
||||
],
|
||||
});
|
||||
|
||||
describe('CostsPanel — filtering the ledger', () => {
|
||||
beforeEach(seedAlice);
|
||||
|
||||
const payment: Payment = { id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' };
|
||||
// The payment comes with the settlement, which can arrive after the expenses: a filter is
|
||||
// only tested against it once both are on screen.
|
||||
const ledgerLoaded = async () => {
|
||||
await screen.findByText('Taxi');
|
||||
await screen.findByText('Payment');
|
||||
};
|
||||
|
||||
it('FE-W5COSTS-014: "Paid by me" keeps only the expenses I fronted', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] });
|
||||
|
||||
await ledgerLoaded();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Paid by me' }));
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument();
|
||||
// A transfer I'm part of stays visible under "mine".
|
||||
expect(screen.getByText('Payment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5COSTS-015: "I\'m owed" keeps the expenses I am net positive on and drops payments', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] });
|
||||
|
||||
await ledgerLoaded();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: "I'm owed" }));
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5COSTS-016: the category filter narrows the ledger and hides payments', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] });
|
||||
|
||||
await ledgerLoaded();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Transport' }));
|
||||
|
||||
expect(screen.getByText('Taxi')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dinner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5COSTS-017: picking a single day banners it with that day’s total', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] });
|
||||
|
||||
await ledgerLoaded();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }));
|
||||
|
||||
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 expense')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument();
|
||||
// The 16th carries the payment, so it is filtered out with the day too.
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => {
|
||||
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
|
||||
// must follow settled_at, the same way it already follows expense_date over
|
||||
// an expense's own created_at.
|
||||
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] });
|
||||
|
||||
await ledgerLoaded();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }));
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Payment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
|
||||
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })]);
|
||||
|
||||
expect(await screen.findByText('No date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5COSTS-019: a search with no hits shows the no-match copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
mount([dinner()]);
|
||||
|
||||
await screen.findByText('Dinner');
|
||||
await user.type(screen.getByPlaceholderText('Search expenses…'), 'zzz');
|
||||
|
||||
expect(screen.getByText('No expenses match your search.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dinner')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// FE-W5COSTS-097: someone who left the trip in the Costs panel (#2604)
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import CostsPanel from './CostsPanel';
|
||||
import { resetFreezeAttempts } from './useFreezeMissingRates';
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
|
||||
// Alice signed in on an AUD trip whose browser holds an AUD quote: the setup this case
|
||||
// ran under beside the bills the server cannot convert in CostsPanel.test.tsx.
|
||||
function seedAudTrip() {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) });
|
||||
clearExchangeRateCache();
|
||||
resetFreezeAttempts();
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }));
|
||||
}
|
||||
|
||||
describe('CostsPanel: someone who left the trip (#2604)', () => {
|
||||
beforeEach(seedAudTrip);
|
||||
afterEach(clearExchangeRateCache);
|
||||
|
||||
it('FE-W5COSTS-097: someone who left the trip is named and keeps their balance in the list (#2604)', async () => {
|
||||
// Carol was removed from the trip; her payer row stays on the expense and the
|
||||
// server still nets her into the balances.
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Boat' }),
|
||||
total_price: 90,
|
||||
payers: [{ user_id: 3, amount: 90, username: 'carol' }],
|
||||
members: [
|
||||
{ user_id: 1, paid: 0, amount: null, username: 'alice' },
|
||||
{ user_id: 2, paid: 0, amount: null, username: 'bob' },
|
||||
{ user_id: 3, paid: 0, amount: null, username: 'carol' },
|
||||
],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -30 },
|
||||
{ user_id: 3, username: 'carol', avatar_url: null, balance: 60 },
|
||||
],
|
||||
flows: [],
|
||||
settlements: [],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />);
|
||||
|
||||
await screen.findByText('Boat');
|
||||
await waitFor(() => expect(screen.getAllByText('carol').length).toBeGreaterThan(0));
|
||||
expect(screen.queryByText('?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,8 @@ describe('CostsPanel: summary footers and won amounts (#2604)', () => {
|
||||
mount([owedByBob()], {
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
|
||||
});
|
||||
await screen.findByText('Museum');
|
||||
// The flow comes with the settlement, which can arrive after the expense.
|
||||
await screen.findByRole('button', { name: /^Settle: bob → you/ });
|
||||
expect(screen.getByRole('region', { name: 'Outstanding amount' })).not.toHaveTextContent("You're all settled up");
|
||||
});
|
||||
|
||||
@@ -136,7 +137,7 @@ describe('CostsPanel: summary footers and won amounts (#2604)', () => {
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
|
||||
});
|
||||
await screen.findByText('Museum');
|
||||
expect(screen.getByRole('button', { name: /^Settle: bob → you, 20,00\s€$/ })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /^Settle: bob → you, 20,00\s€$/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('bob → you').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1094,102 +1094,12 @@ describe('CostsPanel — settle up', () => {
|
||||
|
||||
await screen.findByText('You owe')
|
||||
const card = screen.getByRole('region', { name: 'You owe' }) as HTMLElement
|
||||
expect(within(card).getByText('To')).toBeInTheDocument()
|
||||
expect(await within(card).findByText('To')).toBeInTheDocument()
|
||||
expect(within(card).getByText('bob')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nothing owed to you')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — filtering the ledger', () => {
|
||||
beforeEach(seedAlice)
|
||||
|
||||
const payment: Payment = { id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }
|
||||
|
||||
it('FE-W5COSTS-014: "Paid by me" keeps only the expenses I fronted', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Paid by me' }))
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
// A transfer I'm part of stays visible under "mine".
|
||||
expect(screen.getByText('Payment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-015: "I\'m owed" keeps the expenses I am net positive on and drops payments', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: "I'm owed" }))
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-016: the category filter narrows the ledger and hides payments', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Transport' }))
|
||||
|
||||
expect(screen.getByText('Taxi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-017: picking a single day banners it with that day’s total', async () => {
|
||||
mount([dinner(), taxi()], { settlements: [payment] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
|
||||
|
||||
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 expense')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
// The 16th carries the payment, so it is filtered out with the day too.
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => {
|
||||
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
|
||||
// must follow settled_at, the same way it already follows expense_date over
|
||||
// an expense's own created_at.
|
||||
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter' }))
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Payment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
|
||||
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })])
|
||||
|
||||
expect(await screen.findByText('No date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-019: a search with no hits shows the no-match copy', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([dinner()])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
await user.type(screen.getByPlaceholderText('Search expenses…'), 'zzz')
|
||||
|
||||
expect(screen.getByText('No expenses match your search.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — expense rows', () => {
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
@@ -1222,7 +1132,7 @@ describe('CostsPanel — expense rows', () => {
|
||||
await screen.findByText('Diner')
|
||||
expect(screen.getByText(/\$100\.00 → 50,00 €/)).toBeInTheDocument()
|
||||
// The payer chip and the settlement row are converted the same way.
|
||||
expect(screen.getByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-078: a booked rate outlives the live one, for the expense and for the transfer', async () => {
|
||||
@@ -1242,7 +1152,7 @@ describe('CostsPanel — expense rows', () => {
|
||||
expect(screen.getByText(/\$120\.00 → 100,00 €/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/60,00 €/)).toBeNull()
|
||||
// The settled transfer is read back the same way: 24 USD booked at 1.2 is 20 euro.
|
||||
expect(screen.getByText(/\$24\.00 → 20,00 €/)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/\$24\.00 → 20,00 €/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-022: deleting an expense removes it, and a failure is reported', async () => {
|
||||
@@ -2527,7 +2437,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
// The hotel's split chips can carry the same figure, so look past them to the payment.
|
||||
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
|
||||
expect(outsideChips('$395.77')).toHaveLength(1)
|
||||
@@ -2777,33 +2687,4 @@ describe('CostsPanel: a bill the server cannot convert', () => {
|
||||
// currency when it has no quote of its own.
|
||||
expect(asked.every(a => a.base === 'IRR' && a.baseRate === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-097: someone who left the trip is named and keeps their balance in the list (#2604)', async () => {
|
||||
// Carol was removed from the trip; her payer row stays on the expense and the
|
||||
// server still nets her into the balances.
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Boat' }), total_price: 90,
|
||||
payers: [{ user_id: 3, amount: 90, username: 'carol' }],
|
||||
members: [{ user_id: 1, paid: 0, amount: null, username: 'alice' }, { user_id: 2, paid: 0, amount: null, username: 'bob' }, { user_id: 3, paid: 0, amount: null, username: 'carol' }],
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -30 },
|
||||
{ user_id: 3, username: 'carol', avatar_url: null, balance: 60 },
|
||||
],
|
||||
flows: [],
|
||||
settlements: [],
|
||||
})
|
||||
),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Boat')
|
||||
await waitFor(() => expect(screen.getAllByText('carol').length).toBeGreaterThan(0))
|
||||
expect(screen.queryByText('?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user