Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2b2e41a
fix(mobile): parse raw HTTP and route params
iscekic Aug 25, 2026
bb0c285
refactor(mobile): infer review contracts and scope platforms
iscekic Aug 25, 2026
68211dc
feat(web): add org credit and invoice page procedures
iscekic Aug 25, 2026
90b3702
fix(mobile): key credits queries by user id
iscekic Aug 25, 2026
eed9d29
feat(mobile): page org credits and invoices
iscekic Aug 25, 2026
a658149
fix(mobile): route list freshness and single home sessions query
iscekic Aug 25, 2026
78a67d7
Merge remote-tracking branch 'origin/main' into audit-w7b-data-contra…
iscekic Aug 25, 2026
560a62d
fix(mobile): narrow actionRequired reason across base merge
iscekic Aug 25, 2026
df3f68a
Merge remote-tracking branch 'origin/main' into audit-w7b-data-contra…
iscekic Aug 25, 2026
2fe9e8e
fix(mobile): narrow actionRequired reason across base merge
iscekic Aug 25, 2026
26ca1c6
Merge remote-tracking branch 'origin/main' into audit-w7b-data-contra…
iscekic Aug 25, 2026
5ae0fb3
fix(mobile): narrow actionRequired reason across base merge
iscekic Aug 25, 2026
69e40b4
Merge branch 'audit-w7b-data-contracts-dffd' into audit-w7b-data-cont…
iscekic Aug 25, 2026
b1d21b6
Merge branch 'audit-w7b-data-contracts-dffd-s2' into audit-w7b-data-c…
iscekic Aug 25, 2026
e066c45
Merge remote-tracking branch 'origin/main' into audit-w7b-data-contra…
iscekic Aug 25, 2026
b4f432e
Merge branch 'audit-w7b-data-contracts-dffd' into audit-w7b-data-cont…
iscekic Aug 25, 2026
d94e472
Merge branch 'audit-w7b-data-contracts-dffd-s2' into audit-w7b-data-c…
iscekic Aug 25, 2026
00c5edf
test(web): fix stripe invoice page test spy isolation
iscekic Aug 25, 2026
d0b6b13
fix(mobile): fix org ledger i18n format and unused exports
iscekic Aug 25, 2026
74618ac
feat(i18n): translate org credit and invoice keys
iscekic Aug 25, 2026
03280ca
fix(mobile): remove dead new-session keys from level 3
iscekic Aug 26, 2026
08dc2ee
Merge branch 'audit-w7b-data-contracts-dffd-s2' into audit-w7b-data-c…
iscekic Aug 26, 2026
3c850d4
refactor(web): reuse unified invoice mapping in array path
iscekic Aug 26, 2026
f2bc7ed
Merge branch 'audit-w7b-data-contracts-dffd-s2' into audit-w7b-data-c…
iscekic Aug 26, 2026
97efebf
refactor(mobile): derive org ledger types from page procedures
iscekic Aug 26, 2026
12df0a0
Merge remote-tracking branch 'origin/main' into audit-w7b-data-contra…
iscekic Aug 26, 2026
41a8a81
Merge branch 'audit-w7b-data-contracts-dffd' into audit-w7b-data-cont…
iscekic Aug 26, 2026
5b2c8b8
Merge branch 'audit-w7b-data-contracts-dffd-s2' into audit-w7b-data-c…
iscekic Aug 26, 2026
914f82b
fix(i18n): align org ledger copy with existing prReview wording
iscekic Aug 26, 2026
5bf7730
Merge remote-tracking branch 'origin/main' into audit-w7b-data-contra…
iscekic Aug 26, 2026
97f488e
Merge branch 'audit-w7b-data-contracts-dffd' into audit-w7b-data-cont…
iscekic Aug 26, 2026
449b012
Merge branch 'audit-w7b-data-contracts-dffd-s2' into audit-w7b-data-c…
iscekic Aug 26, 2026
5fd5de0
fix(mobile): separate later-page failures from refetch failures
iscekic Aug 26, 2026
d27fe8b
fix(web): make ledger and invoice cursors honest
iscekic Aug 26, 2026
6cf2d2f
Merge branch 'audit-w7b-data-contracts-dffd-s2' into audit-w7b-data-c…
iscekic Aug 26, 2026
88add67
feat(web): add org credit and invoice page procedures
iscekic Aug 25, 2026
3fb80d4
test(web): fix stripe invoice page test spy isolation
iscekic Aug 25, 2026
f791d17
refactor(web): reuse unified invoice mapping in array path
iscekic Aug 26, 2026
69835e4
fix(web): make ledger and invoice cursors honest
iscekic Aug 26, 2026
e21c20b
Merge remote-tracking branch 'origin/audit-w7b-data-contracts-dffd-s2…
iscekic Aug 26, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { TabScreenScrollView } from '@/components/tab-screen';
import { i18n } from '@/i18n';
import { useGitHubStatus, useGitLabStatus } from '@/lib/hooks/use-code-reviewer';
import { useReviewList } from '@/lib/hooks/use-code-reviews';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';
import { cn, parseTimestamp, timeAgo } from '@/lib/utils';

// Tone classes stay mobile-local; the label is the translated catalog key
Expand Down Expand Up @@ -60,6 +61,7 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) {
const router = useRouter();
const { t } = useTranslation();
const { data, isLoading, isError, isFetching, error, refetch } = useReviewList(scope);
useRouteForegroundRefresh([[['codeReviews']]]);
const githubStatus = useGitHubStatus(scope);
const gitlabStatus = useGitLabStatus(scope);
const hasConnectedProvider =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
/* eslint-disable max-lines -- cohesive mounted suite for the credit-activity screen state contract */
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */

// Credit-activity screen state contract: loading skeleton, first-page error
// (retryable vs. permanent NOT_FOUND/FORBIDDEN/UNAUTHORIZED no-retry), the empty
// state, the `hasMore` footer (truncated string + Load more, busy while a page is
// loading), and the later-page failure footer (rows kept + Retry). The query layer
// is mocked so each state is driven directly through the screen JSX.

import { createElement, type ReactElement } from 'react';
import { act } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { renderWithProviders } from '@/test/render-with-providers';

import '@/i18n';
import { OrganizationCreditActivityScreen } from './credit-activity-screen';

const pageQuery = vi.hoisted(() => ({
isPending: false,
isError: false,
isFetchNextPageError: false,
isFetching: false,
isFetchingNextPage: false,
data: null as unknown,
error: null as unknown,
refetch: vi.fn(),
fetchNextPage: vi.fn(),
}));

const pageHook = vi.hoisted(() => ({
entries: [] as unknown[],
hasMore: false,
}));

const queryErrors = vi.hoisted(() => ({
errors: [] as { variant?: string; onRetry?: () => void }[],
}));

const buttons = vi.hoisted(() => ({
rendered: [] as {
children?: unknown;
onPress?: () => void;
accessibilityLabel?: string;
loading?: boolean;
}[],
}));

vi.mock('@/lib/hooks/use-organization-queries', () => ({
useOrgBoundary: () => ({
organizationId: 'org-1',
role: 'owner',
org: { organizationId: 'org-1', role: 'owner' },
orgs: [{ organizationId: 'org-1', role: 'owner' }],
isLoading: false,
isResolving: false,
isError: false,
}),
useOrgCreditTransactionsPage: () => ({
query: pageQuery,
entries: pageHook.entries,
hasMore: pageHook.hasMore,
}),
}));

vi.mock('@/components/tab-screen', () => ({
useTabBarBottomPadding: () => 0,
}));

vi.mock('@/lib/hooks/use-route-foreground-refresh', () => ({
useRouteForegroundRefresh: vi.fn(),
}));

vi.mock('@/lib/organization-context', () => ({
useOrganization: () => ({
organizationId: 'org-1',
isLoaded: true,
setOrganizationId: vi.fn(),
}),
}));

vi.mock('@/lib/org-deep-link', () => ({
reconcileOrgDeepLink: () => ({
effectiveOrganizationId: 'org-1',
validatedOrg: undefined,
queryOrganizationId: 'org-1',
shouldPersistOverride: false,
isResolving: false,
}),
}));

vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({}),
}));

vi.mock('@kilocode/app-shared/utils', () => ({
fromMicrodollars: (microdollars: number) => microdollars / 1_000_000,
}));

vi.mock('@/lib/format', () => ({
formatDate: String,
formatMoney: (amount: number) => `$${amount}`,
}));

vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
firstNonEmpty: (...args: (string | null | undefined)[]) =>
args.find(value => value != null && value !== '') ?? '',
parseTimestamp: (value: string) => new Date(value),
}));

vi.mock('@/components/empty-state', () => ({
EmptyState: ({ title }: { title: string }) => `EMPTY_STATE:${title}`,
}));

vi.mock('@/components/query-error', () => ({
QueryError: (props: { variant?: string; onRetry?: () => void }) => {
queryErrors.errors.push(props);
return null;
},
}));

vi.mock('@/components/organization/organization-boundary', () => ({
OrganizationBoundary: () => null,
}));

vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null }));

vi.mock('@/components/ui/button', () => ({
Button: (props: {
children?: unknown;
onPress?: () => void;
accessibilityLabel?: string;
loading?: boolean;
}) => {
buttons.rendered.push(props);
return props.children;
},
}));

vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));

vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

vi.mock('@/components/ui/icons', () => ({ Receipt: 'Receipt' }));

vi.mock('react-native-reanimated', () => ({
default: { View: 'AnimatedView' },
FadeIn: { duration: () => ({}) },
FadeOut: { duration: () => ({}) },
}));

vi.mock('react-native', () => ({
View: 'View',
FlatList: (props: {
data?: unknown[];
renderItem?: (info: { item: unknown; index: number }) => ReactElement;
ListEmptyComponent?: ReactElement;
ListFooterComponent?: ReactElement | null;
}) => {
const data = props.data ?? [];
if (data.length === 0) {
return props.ListEmptyComponent ?? null;
}
return createElement(
'View',
null,
data.map((item, index) => props.renderItem?.({ item, index })),
props.ListFooterComponent ?? null
);
},
}));

const TRANSACTION = {
id: 't1',
amount_microdollars: 1_000_000,
description: 'Top-up',
credit_category: null,
created_at: '2026-01-01T00:00:00.000Z',
expiry_date: null,
};

function collectText(node: unknown): string[] {
if (node == null) {
return [];
}
if (typeof node === 'string') {
return [node];
}
if (Array.isArray(node)) {
return node.flatMap(item => collectText(item));
}
if (typeof node === 'object' && 'children' in node) {
return collectText((node as { children?: unknown }).children);
}
return [];
}

async function renderScreen(): Promise<string[]> {
const { renderer } = await renderWithProviders(createElement(OrganizationCreditActivityScreen));
return collectText(renderer.toJSON());
}

beforeEach(() => {
pageQuery.isPending = false;
pageQuery.isError = false;
pageQuery.isFetchNextPageError = false;
pageQuery.isFetching = false;
pageQuery.isFetchingNextPage = false;
pageQuery.data = null;
pageQuery.error = null;
pageQuery.refetch.mockClear();
pageQuery.fetchNextPage.mockClear();
pageHook.entries = [];
pageHook.hasMore = false;
queryErrors.errors = [];
buttons.rendered = [];
});

describe('OrganizationCreditActivityScreen loading', () => {
it('renders the loading skeleton while the first page is pending', async () => {
pageQuery.isPending = true;

const texts = await renderScreen();

expect(texts).not.toContain('No credit activity');
expect(queryErrors.errors).toHaveLength(0);
});
});

describe('OrganizationCreditActivityScreen first-page errors', () => {
it('renders a retryable neutral error with Retry on a server failure', async () => {
pageQuery.data = { pages: [] };
pageQuery.isError = true;
pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } };

await renderScreen();

expect(queryErrors.errors).toHaveLength(1);
expect(queryErrors.errors[0]?.variant).toBe('neutral');
expect(typeof queryErrors.errors[0]?.onRetry).toBe('function');
});

it('renders a permanent not-found state with no Retry on NOT_FOUND', async () => {
pageQuery.data = { pages: [] };
pageQuery.isError = true;
pageQuery.error = { data: { code: 'NOT_FOUND' } };

await renderScreen();

expect(queryErrors.errors).toHaveLength(1);
expect(queryErrors.errors[0]?.variant).toBe('not-found');
expect(queryErrors.errors[0]?.onRetry).toBeUndefined();
});

it.each(['FORBIDDEN', 'UNAUTHORIZED'] as const)(
'renders a permanent permission state with no Retry on %s',
async code => {
pageQuery.data = { pages: [] };
pageQuery.isError = true;
pageQuery.error = { data: { code } };

await renderScreen();

expect(queryErrors.errors).toHaveLength(1);
expect(queryErrors.errors[0]?.variant).toBe('permission');
expect(queryErrors.errors[0]?.onRetry).toBeUndefined();
}
);
});

describe('OrganizationCreditActivityScreen empty', () => {
it('renders the empty state when the first page has no entries', async () => {
pageQuery.data = { pages: [{ entries: [], nextCursor: null, hasMore: false }] };

const texts = await renderScreen();

expect(texts).toContain('EMPTY_STATE:No credit activity');
expect(queryErrors.errors).toHaveLength(0);
});
});

describe('OrganizationCreditActivityScreen pagination', () => {
it('renders the truncated footer with Load more when hasMore is true', async () => {
pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] };
pageHook.entries = [TRANSACTION];
pageHook.hasMore = true;

const texts = await renderScreen();

expect(texts).toContain('Top-up');
expect(texts).toContain('Older credit activity is available.');

const loadMore = buttons.rendered.find(button => button.accessibilityLabel === 'Load more');
expect(loadMore).toBeDefined();
expect(loadMore?.loading).toBe(false);

act(() => {
loadMore?.onPress?.();
});
expect(pageQuery.fetchNextPage).toHaveBeenCalledTimes(1);
});

it('marks Load more busy while the next page is loading', async () => {
pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] };
pageQuery.isFetchingNextPage = true;
pageHook.entries = [TRANSACTION];
pageHook.hasMore = true;

await renderScreen();

const loadMore = buttons.rendered.find(button => button.accessibilityLabel === 'Load more');
expect(loadMore?.loading).toBe(true);
});

it('keeps rows and shows a Retry footer when a later page fails', async () => {
pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] };
pageQuery.isFetchNextPageError = true;
pageQuery.isError = true;
pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } };
pageHook.entries = [TRANSACTION];
pageHook.hasMore = true;

const texts = await renderScreen();

expect(texts).toContain('Top-up');
expect(texts).toContain("Couldn't load more.");
expect(texts).not.toContain('Older credit activity is available.');

const retry = buttons.rendered.find(button => button.accessibilityLabel === 'Retry');
expect(retry).toBeDefined();

act(() => {
retry?.onPress?.();
});
expect(pageQuery.fetchNextPage).toHaveBeenCalledTimes(1);
});

it('keeps Load more when a background refetch fails after pages loaded', async () => {
pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] };
pageQuery.isError = true;
pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } };
pageHook.entries = [TRANSACTION];
pageHook.hasMore = true;

const texts = await renderScreen();

expect(texts).toContain('Top-up');
expect(texts).toContain('Older credit activity is available.');
expect(texts).not.toContain("Couldn't load more.");
expect(buttons.rendered.some(button => button.accessibilityLabel === 'Load more')).toBe(true);
});
});
Loading