Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions apps/mobile/src/components/context-control.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ const LIGHT_COLORS = {
border: 'rgba(20, 15, 10, 0.09)',
primary: '#4F5A10',
};
// The provider now announces the lost-membership recovery through the
// toast adapter, whose module graph reaches react-native's Flow source
// the mounted transform cannot parse. Suites mock the adapter (not the
// sonner package) so the mock sits at the edge that imports it.
vi.mock('@/lib/a11y/announcing-toast', () => ({
announcingToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));

vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => auth }));
vi.mock('@/lib/auth/logout-cleanup', () => ({ unregisterActivityTokensAndTombstone: vi.fn() }));
vi.mock('expo-secure-store', () => ({
Expand Down Expand Up @@ -259,7 +267,10 @@ describe.each(['ios', 'android'])('ContextControl on %s', os => {
it.each([
{ choice: 'Personal', expected: null },
{ choice: name, expected: 'org-a' },
{ choice: 'Cancel', expected: 'org-missing' },
// The stored organization is missing from the list, so the provider
// recovers to the first listed membership before the sheet opens; a
// Cancel is then a plain dismissal of that recovered scope.
{ choice: 'Cancel', expected: 'org-a' },
])(
'applies the picked account $choice without an implicit reset',
async ({ choice, expected }) => {
Expand All @@ -278,13 +289,18 @@ describe.each(['ios', 'android'])('ContextControl on %s', os => {
storage.read.mockResolvedValue('org-missing');
const ui = await mount();
await waitFor(() => !picker(ui).props.disabled);
// The stored organization is missing from the list, so the provider has
// already recovered to the first listed membership before the sheet opens.
await waitFor(
() => ui.renderer.root.findByType('GlobalScope' as ElementType).props.id === 'org-a'
);
await openSheet(ui);
// The native sheet's dismiss path, not a row: the scope stays as it was.
await act(() => {
(backdrop(ui).props.onClose as () => void)();
});
expect(rows(ui)).toHaveLength(0);
expect(ui.renderer.root.findByType('GlobalScope' as ElementType).props.id).toBe('org-missing');
expect(ui.renderer.root.findByType('GlobalScope' as ElementType).props.id).toBe('org-a');
});

it('separates the rows and exposes the current account through the radio state', async () => {
Expand Down Expand Up @@ -332,9 +348,10 @@ describe.each(['ios', 'android'])('ContextControl on %s', os => {
it.each([
// An absent stored choice is 'not chosen yet': the provider's login default
// resolves to the first organization, so the picker must mark that row. A
// stored organization missing from the list marks no row at all.
// stored organization missing from the list recovers to that first
// membership before the sheet opens, so its row is marked too.
{ stored: null, checked: name },
{ stored: 'org-missing', checked: undefined },
{ stored: 'org-missing', checked: name },
])(
'marks the account the user is actually on when the stored one is missing (stored=$stored)',
async ({ stored, checked }) => {
Expand All @@ -347,7 +364,7 @@ describe.each(['ios', 'android'])('ContextControl on %s', os => {
rows(ui)
.filter(row => row.checked)
.map(row => row.label)
).toEqual(checked === undefined ? [] : [checked]);
).toEqual([checked]);
}
);

Expand Down Expand Up @@ -395,18 +412,19 @@ describe.each(['ios', 'android'])('ContextControl on %s', os => {
});
});

it('recovers an unavailable organization through Personal after an empty membership result', async () => {
it('recovers a stored organization missing from an empty membership list to Personal', async () => {
storage.read.mockResolvedValue('org-missing');
list.mockResolvedValue([]);
const ui = await mount();
await waitFor(() => texts(ui).includes('Organization unavailable'));
expect(texts(ui)).not.toContain('Retry');
expect(ui.renderer.root.findByType('GlobalScope' as ElementType).props.id).toBe('org-missing');
// The stored organization is missing from the fetched list, so the
// provider switches to Personal (no membership remains) on the first
// snapshot instead of rendering the dead selection's error surface.
await waitFor(
() => ui.renderer.root.findByType('GlobalScope' as ElementType).props.id === null
);
expect(texts(ui)).not.toContain('Organization unavailable');
await openSheet(ui);
expect(rows(ui).map(row => row.label)).toEqual(['Personal']);
await chooseRow(ui, 'Personal');
expect(texts(ui)).toContain('Personal');
expect(texts(ui)).not.toContain('Organization unavailable');
});

it.each([false, true])('retries only the failed name query, read-only=%s', async readOnly => {
Expand Down Expand Up @@ -507,6 +525,9 @@ describe.each(['ios', 'android'])('ContextControl on %s', os => {
it.each([
{ organizationId: null, expected: 'Personal' },
{ organizationId: 'org-a', expected: name },
// The stored global scope ('global-org') is missing from the fetched
// list, so the provider recovers to the first listed membership; the
// explicit per-session scope stays read-only and independent of that.
{ organizationId: 'org-missing', expected: 'Organization unavailable' },
])(
'keeps the resolved session scope $organizationId read-only and independent',
Expand All @@ -515,17 +536,22 @@ describe.each(['ios', 'android'])('ContextControl on %s', os => {
const ui = await mount({ scope: { organizationId, isResolved: true } });
await waitFor(() => texts(ui).includes(expected));
expect(ui.renderer.root.findAll(node => node.type === Pressable)).toHaveLength(0);
expect(ui.renderer.root.findByType('GlobalScope' as ElementType).props.id).toBe('global-org');
expect(ui.renderer.root.findByType('GlobalScope' as ElementType).props.id).toBe('org-a');
}
);

it('does not infer Personal or offer a picker for unresolved session scope', async () => {
storage.read.mockResolvedValue('global-org');
const ui = await mount({ scope: { organizationId: null, isResolved: false } });
// The stored global scope is missing from the list, so the provider
// recovers to the first listed membership; the unresolved session scope
// still infers nothing and offers no picker.
await waitFor(
() => ui.renderer.root.findByType('GlobalScope' as ElementType).props.id === 'org-a'
);
expect(texts(ui)).not.toContain('Personal');
expect(ui.renderer.root.findAll(node => node.type === Skeleton)).toHaveLength(1);
expect(ui.renderer.root.findAll(node => node.type === Pressable)).toHaveLength(0);
expect(ui.renderer.root.findByType('GlobalScope' as ElementType).props.id).toBe('global-org');
});

it.each(['pending', 'failed'])(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ const list = vi.hoisted(() => vi.fn());
const storage = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn(), remove: vi.fn() }));
const auth = vi.hoisted(() => ({ token: 'token' as string | undefined }));

// The mounted OrganizationProvider announces a lost-membership switch through
// the toast adapter, whose module graph reaches react-native's Flow source the
// mounted transform cannot parse. The mock sits at the adapter, so the visual
// toast package never loads here.
vi.mock('@/lib/a11y/announcing-toast', () => ({
announcingToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));

vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => auth }));
vi.mock('@/lib/auth/logout-cleanup', () => ({ unregisterActivityTokensAndTombstone: vi.fn() }));
vi.mock('expo-secure-store', () => ({
Expand Down Expand Up @@ -201,10 +209,14 @@ describe('PrReviewContextLine', () => {
expect(line(ui)).toBeDefined();
});

it('names the unavailable scope when the persisted organization left the list', async () => {
it('recovers a persisted organization that left the list to the first membership', async () => {
storage.read.mockResolvedValue('gone-org');
const ui = await mount();
await waitFor(() => texts(ui).includes('For Organization'));
expect(texts(ui)).toContain('Organization unavailable');
// The first settled list omits the stored organization, so the provider
// switches to the first membership and persists it instead of naming a
// dead selection the reader cannot act on.
await waitFor(() => texts(ui).includes('For Acme'));
expect(texts(ui)).not.toContain('Organization unavailable');
expect(storage.write).toHaveBeenCalledWith(ORGANIZATION_STORAGE_KEY, 'org-a');
});
});
48 changes: 41 additions & 7 deletions apps/mobile/src/components/profile-credits-card.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ const currentUser = vi.hoisted(() => ({
/** Window the card lays out in; a narrow one must stack the credits header. */
const windowDims = vi.hoisted(() => ({ width: 390, height: 844, fontScale: 1, scale: 2 }));

// The provider now announces the lost-membership recovery through the
// toast adapter, whose module graph reaches react-native's Flow source
// the mounted transform cannot parse. Suites mock the adapter (not the
// sonner package) so the mock sits at the edge that imports it.
vi.mock('@/lib/a11y/announcing-toast', () => ({
announcingToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));

vi.mock('@/lib/trpc', () => ({
useTRPC: () => ({
user: {
Expand Down Expand Up @@ -87,11 +95,17 @@ vi.mock('expo-router', () => ({
useRouter: () => ({ push: routerPush }),
}));

// The mounted OrganizationProvider resolves its default from this list; an
// empty settled list keeps the card's own selection assertions unchanged.
// The mounted OrganizationProvider reads the shared memberships from this
// list. The payload is driveable per test: a stored selection missing from
// the fetched list now recovers to the first membership (or Personal), so a
// test staging a stale stored selection seeds `listState.payload` with a
// list that still names it.
const listState = vi.hoisted(() => ({
payload: [] as { organizationId: string; organizationName: string; role: string }[],
}));
vi.mock('@/lib/hooks/use-organizations-list', () => ({
useOrganizationsList: () => ({
data: [],
data: listState.payload,
isFetched: true,
isFetching: false,
isError: false,
Expand Down Expand Up @@ -258,6 +272,9 @@ beforeEach(() => {
savedMetadata.clear();
saveCompletion = undefined;
addCreditsRowProps.latest = undefined;
// The shared memberships the provider reads default to none; tests that
// stage a stored selection set their own payload.
listState.payload = [];
routerPush.mockReset();
storage.read.mockReset().mockImplementation(async (key: string) => {
await Promise.resolve();
Expand Down Expand Up @@ -333,6 +350,7 @@ describe('CreditsCard balance state', () => {
const orgs = [
{ organizationId: 'org-a', organizationName: 'Acme', role: 'owner' },
] as OrgListEntry[];
listState.payload = orgs;
try {
const { texts, unmount } = await mountCard(createTestQueryClient(), orgs);
try {
Expand All @@ -358,16 +376,23 @@ describe('CreditsCard balance state', () => {
label: organizationName,
},
])('uses supplied memberships for the picker: $expected', async state => {
savedMetadata.set(ORGANIZATION_STORAGE_KEY, 'missing-org');
const { renderer, texts, unmount } = await mountCard(
// The staged stored selection is missing from the supplied list, so the
// provider recovers before the card renders: give the shared list the
// same payload the card receives, so the recovery lands on the selection
// the assertions then drive.
listState.payload = state.orgs as OrgListEntry[];
const seededSelection = (state.orgs as OrgListEntry[])[0]?.organizationId;
savedMetadata.set(ORGANIZATION_STORAGE_KEY, seededSelection ?? 'missing-org');
const { renderer, unmount } = await mountCard(
createTestQueryClient(),
state.orgs as OrgListEntry[]
);
await openSheet(renderer);
expect([...accountLabels(renderer), 'Cancel']).toEqual(state.options);
await choose(renderer, 'Cancel');
expect(savedMetadata.get(ORGANIZATION_STORAGE_KEY)).toBe('missing-org');
expect(texts()).toContain('Organization');
expect(savedMetadata.get(ORGANIZATION_STORAGE_KEY)).toBe(
(state.orgs as OrgListEntry[])[0]?.organizationId ?? undefined
);
await openSheet(renderer);
await choose(renderer, state.label);
expect(savedMetadata.get(ORGANIZATION_STORAGE_KEY)).toBe(state.expected ?? undefined);
Expand All @@ -385,6 +410,10 @@ describe('CreditsCard balance state', () => {
{ organizationId: 'org-a', organizationName: 'Supplied organization', role: 'owner' },
{ organizationId: 'org-b', organizationName: 'Latest organization', role: 'owner' },
] as OrgListEntry[];
// The staged 'previous-org' selection must stay in the fetched list, or
// the provider's lost-membership recovery switches it away before the
// save-failure flow under test runs.
listState.payload = orgs;
storage.write.mockRejectedValueOnce(new Error('write failed'));
const { renderer, texts, unmount } = await mountCard(createTestQueryClient(), orgs);
await openSheet(renderer);
Expand Down Expand Up @@ -663,6 +692,9 @@ describe('CreditsCard add-credits entry point', () => {
const orgs = [
{ organizationId: 'org-a', organizationName: 'Acme', role: 'owner' },
] as OrgListEntry[];
// The stored selection must stay in the fetched list, or the provider's
// lost-membership recovery switches it to Personal before the card reads it.
listState.payload = orgs;

const { texts, unmount } = await mountCard(queryClient, orgs);
await waitFor(() => addCreditsRowProps.latest?.url !== undefined);
Expand All @@ -684,6 +716,8 @@ describe('CreditsCard add-credits entry point', () => {
const orgs = [
{ organizationId: 'org-a', organizationName: 'Acme', role: 'member' },
] as OrgListEntry[];
// Same seeding as the money-role case: keep the selection recoverable.
listState.payload = orgs;

const { texts, unmount } = await mountCard(queryClient, orgs);
await waitFor(() => texts().includes('Acme'));
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/az.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/be.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/bg.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/bn.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/bs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/src/i18n/locales/ca.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading