feat: OpenTelemetry LGTM observability, dev tooling, and memoir UX fixes (#31) (#32)

* add staging ios app build script

* feat(api): add OpenTelemetry LGTM stack for local observability

Wire OTel traces, metrics, and logs through a collector to Tempo,
Prometheus, and Loki, with custom LLM instrumentation, dev compose overlay,
Grafana provisioning, env templates, and development.sh auto-start.



* feat: expand observability, harden dev tooling, and fix expo staging UX

Add business and LLM Prometheus metrics with Grafana dashboards, alerting,
and a metrics verification script. Wire telemetry through adapters and core
LLM paths, and document the local LGTM workflow.

Fix development.sh for macOS bash 3.2, open Grafana and eval-web in Chrome,
and repair eval-web auto-open (unbound EVAL_WEB_BROWSER_SCHEDULED). Merge
internal-eval into the main dev script with improved compose handling.

Require EXPO_PUBLIC_* at build time, improve iOS HTTP ATS for staging IPs,
show memoir empty state instead of load errors when no chapters exist, and
add jest env setup plus chapter list response normalization.



* chore: enable Grafana Assistant Cursor plugin



* fix: memoir empty state and repair withdrawn 0020_chapters_book_id stamp

Show empty memoir UI when the chapter list succeeds with no items; treat auth/404 as non-fatal. Extend alembic revision repair so local dev DBs stamped with the removed 0020_chapters_book_id migration can roll back and upgrade to 0019.



---------

Co-authored-by: Kevin <kevin@brighteng.org>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sully
2026-05-20 15:14:13 +08:00
committed by GitHub
parent 81458c7046
commit f09ae248f9
74 changed files with 3793 additions and 375 deletions

View File

@@ -20,8 +20,12 @@
# EXPO_PUBLIC_API_URL=http://127.0.0.1:8000
# EXPO_PUBLIC_WS_URL=ws://127.0.0.1:8000
# --- staging ---
# --- staging(必填,无默认值;示例见 env/staging---
# APP_VARIANT=staging
# EXPO_PUBLIC_APP_VARIANT=staging
EXPO_PUBLIC_API_URL=https://your-api.example.com
EXPO_PUBLIC_WS_URL=wss://your-api.example.com
# EXPO_PUBLIC_API_URL=http://your-staging-host:8000
# EXPO_PUBLIC_WS_URL=ws://your-staging-host:8000
# --- production ---
# EXPO_PUBLIC_API_URL=https://your-api.example.com
# EXPO_PUBLIC_WS_URL=wss://your-api.example.com

View File

@@ -28,7 +28,16 @@ const LOCALES: Record<string, LocaleMessages> = {
const SUPPORTED_LOCALES = ['zh', 'en'] as const;
const PRIMARY_LOCALE = process.env.EXPO_PUBLIC_PRIMARY_LOCALE ?? 'zh';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? '';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL?.trim() ?? '';
const WS_BASE_URL = process.env.EXPO_PUBLIC_WS_URL?.trim() ?? '';
if (!API_BASE_URL || !WS_BASE_URL) {
throw new Error(
'[app.config] Missing EXPO_PUBLIC_API_URL or EXPO_PUBLIC_WS_URL. ' +
'Run `npm run use-env -- <development|staging|production>` in app-expo before prebuild or Metro.',
);
}
const ALLOW_INSECURE_HTTP = API_BASE_URL.startsWith('http://');
const APP_VARIANT =
@@ -176,7 +185,14 @@ export default ({ config }: ConfigContext): ExpoConfig => {
'./plugins/withAndroidCleartextTraffic',
{ enabled: ALLOW_INSECURE_HTTP },
],
['./plugins/withIosInsecureHttp', { enabled: ALLOW_INSECURE_HTTP }],
[
'./plugins/withIosInsecureHttp',
{
enabled: ALLOW_INSECURE_HTTP,
apiUrl: API_BASE_URL,
wsUrl: WS_BASE_URL,
},
],
'expo-router',
[
'expo-splash-screen',

View File

@@ -1,5 +1,6 @@
module.exports = {
preset: 'jest-expo',
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
clearMocks: true,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',

View File

@@ -1,43 +1,81 @@
// @ts-check
/**
* Allow HTTP / WS to staging API host via App Transport Security exception.
* Allow HTTP / WS to staging API hosts via App Transport Security.
*
* Enabled when EXPO_PUBLIC_API_URL uses http:// (same rule as Android cleartext).
* Host is parsed from the URL so IP:port staging endpoints work without hard-coding.
* Collects hosts from both API and WS URLs (IP:port staging often differs only by scheme).
*/
const { withInfoPlist } = require('@expo/config-plugins');
/**
* @param {string | undefined} raw
* @returns {string | null}
*/
function getHttpExceptionHost() {
const raw = process.env.EXPO_PUBLIC_API_URL ?? '';
if (!raw.startsWith('http://')) {
function insecureHttpHostFromUrl(raw) {
if (!raw || !raw.startsWith('http://')) {
return null;
}
try {
return new URL(raw).hostname;
return new URL(raw).hostname || null;
} catch {
return null;
}
}
/**
* @param {string | undefined} raw
* @returns {string | null}
*/
function insecureWsHostFromUrl(raw) {
if (!raw || !raw.startsWith('ws://')) {
return null;
}
try {
return new URL(raw).hostname || null;
} catch {
return null;
}
}
/**
* @param {string | undefined} apiUrl
* @param {string | undefined} wsUrl
* @returns {string[]}
*/
function collectInsecureHosts(apiUrl, wsUrl) {
const hosts = new Set(
[insecureHttpHostFromUrl(apiUrl), insecureWsHostFromUrl(wsUrl)].filter(
(h) => typeof h === 'string' && h.length > 0,
),
);
return [...hosts];
}
/**
* @param {string} host
*/
function isIpv4Literal(host) {
return /^\d{1,3}(\.\d{1,3}){3}$/u.test(host);
}
/**
* @param {import('expo/config').ExpoConfig} config
* @param {{ enabled?: boolean }} props
* @param {{ enabled?: boolean; apiUrl?: string; wsUrl?: string }} props
*/
function withIosInsecureHttp(config, props = {}) {
const enabled = props.enabled ?? false;
const apiUrl = props.apiUrl ?? process.env.EXPO_PUBLIC_API_URL ?? '';
const wsUrl = props.wsUrl ?? process.env.EXPO_PUBLIC_WS_URL ?? '';
return withInfoPlist(config, (mod) => {
if (!enabled) {
return mod;
}
const host = getHttpExceptionHost();
if (!host) {
const hosts = collectInsecureHosts(apiUrl, wsUrl);
if (hosts.length === 0) {
console.warn(
'[withIosInsecureHttp] enabled but EXPO_PUBLIC_API_URL has no http host; skipping ATS exception.',
'[withIosInsecureHttp] enabled but no http/ws hosts found in apiUrl/wsUrl; skipping ATS exception.',
);
return mod;
}
@@ -45,17 +83,32 @@ function withIosInsecureHttp(config, props = {}) {
const existing = mod.modResults.NSAppTransportSecurity ?? {};
const existingDomains = existing.NSExceptionDomains ?? {};
/** @type {Record<string, object>} */
const exceptionDomains = { ...existingDomains };
for (const host of hosts) {
exceptionDomains[host] = {
NSExceptionAllowsInsecureHTTPLoads: true,
// IP literals have no subdomains; false avoids odd ATS behavior on some iOS versions.
NSIncludesSubdomains: !isIpv4Literal(host),
NSExceptionRequiresForwardSecrecy: false,
};
}
mod.modResults.NSAppTransportSecurity = {
...existing,
NSExceptionDomains: {
...existingDomains,
[host]: {
NSExceptionAllowsInsecureHTTPLoads: true,
NSIncludesSubdomains: true,
},
},
/**
* Staging often uses bare IP:port HTTP. Domain exceptions alone can fail on
* newer iOS builds; allow cleartext while this plugin is enabled (http:// API only).
*/
NSAllowsArbitraryLoads: true,
NSExceptionDomains: exceptionDomains,
};
console.log(
`[withIosInsecureHttp] ATS cleartext enabled for host(s): ${hosts.join(', ')}`,
);
return mod;
});
}

View File

@@ -1,5 +1,5 @@
import { Image } from 'expo-image';
import { router } from 'expo-router';
import { router, useFocusEffect } from 'expo-router';
import React, {
useCallback,
useEffect,
@@ -17,22 +17,26 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { FileText } from 'lucide-react-native';
import { FileText, MessageCirclePlus } from 'lucide-react-native';
import { Icon } from '@/components/ui/icon';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { ScreenGutter } from '@/constants/layout';
import { ApiError, NetworkError } from '@/core/api/types';
import { config, shouldShowAboutBackendUrl } from '@/core/config';
import { useTypography } from '@/core/typography-context';
import {
buildFrameworkChapterPlaceholders,
mergeFrameworkChaptersWithFetched,
} from '@/features/memoir/framework-chapter-keys';
import {
hasAnyMemoirDraftingActivity,
memoirDraftCharsRemaining,
memoirDraftHasStarted,
resolvedChapterCategory,
} from '@/features/memoir/draft-progress';
import { useSession } from '@/features/auth/hooks';
import {
useChapters,
useCheckCoverGeneration,
@@ -286,13 +290,41 @@ function ChapterCard({
return null;
}
function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
function formatChapterLoadErrorHint(error: unknown): string | null {
if (!shouldShowAboutBackendUrl()) return null;
if (error instanceof NetworkError) {
return `${error.message}\n${config.apiBaseUrl}`;
}
if (error instanceof ApiError) {
return `HTTP ${error.status}: ${error.message}`;
}
if (error instanceof Error) return error.message;
return null;
}
function MemoirLoadError({
error,
onRetry,
}: {
error: unknown;
onRetry: () => void;
}) {
const { t } = useTranslation('memoir');
const hint = formatChapterLoadErrorHint(error);
return (
<View className="items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/20 p-10">
<Text variant="bodyLarge" className="text-center text-destructive">
{t('loadErrorMessage')}
</Text>
{hint ? (
<Text
variant="bodySmall"
className="text-center text-muted-foreground"
selectable
>
{hint}
</Text>
) : null}
<Pressable
className="rounded-lg bg-primary px-6 py-3 active:opacity-90"
style={{ borderCurve: 'continuous' }}
@@ -309,10 +341,47 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
);
}
function MemoirEmptyState({ onStartChat }: { onStartChat: () => void }) {
const { t } = useTranslation('memoir');
return (
<Pressable
className="items-center gap-6 rounded-2xl bg-muted/30 p-10 active:opacity-90"
style={{ borderCurve: 'continuous' }}
onPress={onStartChat}
>
<Icon as={MessageCirclePlus} className="text-primary" size={40} />
<View className="items-center gap-3">
<Text variant="h2" className="text-center font-display text-primary">
{t('emptyTitle')}
</Text>
<Text
variant="bodyLarge"
className="text-center font-medium text-muted-foreground"
>
{t('emptySubtitle')}
</Text>
</View>
</Pressable>
);
}
export default function MemoirScreen() {
const { t } = useTranslation('memoir');
const { viewModels: chapters, isLoading, isError, refetch } = useChapters();
const { data: memoirState, refetch: refetchMemoirState } = useMemoirState();
const { isAuthenticated } = useSession();
const {
viewModels: chapters,
isLoading,
hasCompletedChapters,
isEmptyList,
showLoadError,
error: chaptersError,
refetch,
} = useChapters({ enabled: isAuthenticated });
const {
data: memoirState,
isLoading: isMemoirStateLoading,
refetch: refetchMemoirState,
} = useMemoirState({ enabled: isAuthenticated });
const checkCover = useCheckCoverGeneration();
const [refreshing, setRefreshing] = useState(false);
const didRunInitialCoverCheckRef = useRef(false);
@@ -327,6 +396,29 @@ export default function MemoirScreen() {
[frameworkPlaceholders, chapters],
);
const hasDraftingActivity = useMemo(() => {
if (hasCompletedChapters) return true;
if (chapters.some((ch) => !ch.isEmpty || ch.wordCount > 0)) return true;
return hasAnyMemoirDraftingActivity(memoirState?.slots);
}, [chapters, hasCompletedChapters, memoirState?.slots]);
const isBootstrapping =
isLoading || (isEmptyList && isMemoirStateLoading);
const isEmptyMemoir =
!isBootstrapping &&
!showLoadError &&
isEmptyList &&
!hasDraftingActivity;
useFocusEffect(
useCallback(() => {
if (!isAuthenticated) return;
void refetch();
void refetchMemoirState();
}, [isAuthenticated, refetch, refetchMemoirState]),
);
useEffect(() => {
if (didRunInitialCoverCheckRef.current) return;
didRunInitialCoverCheckRef.current = true;
@@ -336,7 +428,7 @@ export default function MemoirScreen() {
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await checkCover.mutateAsync(undefined);
await checkCover.mutateAsync(undefined).catch(() => undefined);
await Promise.all([refetch(), refetchMemoirState()]);
} finally {
setRefreshing(false);
@@ -347,6 +439,10 @@ export default function MemoirScreen() {
router.push(`/(main)/chapter/${chapterId}`);
}, []);
const handleStartChat = useCallback(() => {
router.push('/(tabs)');
}, []);
return (
<View className="flex-1 bg-background">
<SafeAreaView className="flex-1" edges={['top']}>
@@ -361,19 +457,27 @@ export default function MemoirScreen() {
paddingTop: 24,
paddingBottom: 96,
gap: 24,
...(!isLoading && isError
...(!isBootstrapping && (showLoadError || isEmptyMemoir)
? { flexGrow: 1, justifyContent: 'center' }
: {}),
}}
>
{isLoading ? (
{isBootstrapping ? (
<>
<ChapterCardSkeleton />
<ChapterCardSkeleton />
<ChapterCardSkeleton />
</>
) : isError ? (
<MemoirLoadError onRetry={() => void refetch()} />
) : showLoadError ? (
<MemoirLoadError
error={chaptersError}
onRetry={() => {
void refetch();
void refetchMemoirState();
}}
/>
) : isEmptyMemoir ? (
<MemoirEmptyState onStartChat={handleStartChat} />
) : (
displayChapters.map((item) => {
const variant = getChapterVariant(item);

View File

@@ -4,6 +4,57 @@ function trimTrailingSlashes(value: string): string {
export type AppVariant = 'development' | 'staging' | 'production';
const MISSING_ENV_HINT =
'Run `npm run use-env -- <development|staging|production>` in app-expo, ' +
'then restart Metro or re-run `expo prebuild` before building.';
/**
* EXPO_PUBLIC_* must be set at bundle time (Metro / EAS / Xcode Archive).
* Refuses silent fallbacks to a hard-coded LAN IP.
*/
export function requirePublicEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`[config] Missing ${name}. ${MISSING_ENV_HINT}`);
}
return value;
}
function parseBackendUrl(raw: string, envName: string): URL {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`[config] Invalid ${envName}: ${raw}`);
}
if (!parsed.protocol || parsed.protocol === ':') {
throw new Error(`[config] ${envName} must include a scheme (http/https or ws/wss): ${raw}`);
}
return parsed;
}
function resolveApiBaseUrl(): string {
const raw = requirePublicEnv('EXPO_PUBLIC_API_URL');
const parsed = parseBackendUrl(raw, 'EXPO_PUBLIC_API_URL');
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`[config] EXPO_PUBLIC_API_URL must use http:// or https:// (got ${parsed.protocol})`,
);
}
return trimTrailingSlashes(raw);
}
function resolveWsBaseUrl(): string {
const raw = requirePublicEnv('EXPO_PUBLIC_WS_URL');
const parsed = parseBackendUrl(raw, 'EXPO_PUBLIC_WS_URL');
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
throw new Error(
`[config] EXPO_PUBLIC_WS_URL must use ws:// or wss:// (got ${parsed.protocol})`,
);
}
return trimTrailingSlashes(raw);
}
function resolveAppVariant(): AppVariant {
const raw = process.env.EXPO_PUBLIC_APP_VARIANT;
if (raw === 'development' || raw === 'staging' || raw === 'production') {
@@ -33,12 +84,8 @@ export function shouldShowAboutBackendUrl(variant: AppVariant = appVariant): boo
export const appVariant = resolveAppVariant();
export const config = {
apiBaseUrl: trimTrailingSlashes(
process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.10.151:8000',
),
wsBaseUrl: trimTrailingSlashes(
process.env.EXPO_PUBLIC_WS_URL ?? 'ws://192.168.10.151:8000',
),
apiBaseUrl: resolveApiBaseUrl(),
wsBaseUrl: resolveWsBaseUrl(),
isDebugMode: __DEV__,
appVariant,
showAboutBackendUrl: shouldShowAboutBackendUrl(),

View File

@@ -6,6 +6,8 @@ import { tokenManager } from '@/core/auth/token-manager';
import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry';
import { getDeviceLanguage } from '@/i18n';
import { memoirKeys } from '@/features/memoir/query-keys';
import { authApi } from './api';
import { authKeys } from './auth-query-keys';
import type {
@@ -126,7 +128,10 @@ function usePostAuthSetup() {
async (tokens: TokenResponse) => {
await tokenManager.setTokens(tokens.access_token, tokens.refresh_token);
queryClient.setQueryData(authKeys.tokenCheck, true);
await queryClient.invalidateQueries({ queryKey: authKeys.session });
await Promise.all([
queryClient.invalidateQueries({ queryKey: authKeys.session }),
queryClient.invalidateQueries({ queryKey: memoirKeys.all }),
]);
},
[queryClient],
);

View File

@@ -1,5 +1,9 @@
import { api } from '@/core/api/client';
import {
isChapterListNotFoundError,
normalizeChapterList,
} from './chapter-list-response';
import type {
Book,
Chapter,
@@ -32,10 +36,18 @@ export const memoirApi = {
return api.post<ExportPdfResponse>('/api/books/export-pdf', { body });
},
fetchChapters(isNew?: boolean) {
return api.get<Chapter[]>('/api/chapters', {
params: isNew !== undefined ? { is_new: isNew } : undefined,
});
async fetchChapters(isNew?: boolean): Promise<Chapter[]> {
try {
const data = await api.get<unknown>('/api/chapters', {
params: isNew !== undefined ? { is_new: isNew } : undefined,
});
return normalizeChapterList(data);
} catch (error) {
if (isChapterListNotFoundError(error)) {
return [];
}
throw error;
}
},
fetchChapterDetail(chapterId: string) {

View File

@@ -0,0 +1,47 @@
import { ApiError, AuthError } from '@/core/api/types';
import type { Chapter } from './types';
/** Normalize GET /api/chapters payload; reject non-arrays without surfacing as query errors. */
export function normalizeChapterList(data: unknown): Chapter[] {
if (data == null) return [];
if (Array.isArray(data)) return data as Chapter[];
return [];
}
export function isChapterListNotFoundError(error: unknown): boolean {
return error instanceof ApiError && error.status === 404;
}
/** 未登录/无权限:不应展示「加载章节失败」(会话层会处理或展示框架位)。 */
export function isChapterListAuthError(error: unknown): boolean {
if (error instanceof AuthError) return true;
return (
error instanceof ApiError &&
(error.status === 401 || error.status === 403)
);
}
/**
* True when GET /api/chapters succeeded but there are no list items (incl. filtered
* non-displayable chapters). Distinct from transport/auth failures.
*/
export function isChapterListEmptySuccess(
isSuccess: boolean,
chapters: Chapter[],
): boolean {
return isSuccess && chapters.length === 0;
}
/** Only show "Could not load chapters" for real failures, not empty memoir or auth redirect. */
export function shouldShowChapterListLoadError(
error: unknown,
isSuccess: boolean,
chapterCount: number,
): boolean {
if (isSuccess && chapterCount === 0) return false;
if (error == null) return false;
if (isChapterListNotFoundError(error)) return false;
if (isChapterListAuthError(error)) return false;
return true;
}

View File

@@ -37,6 +37,16 @@ export function interviewStageHasSnippetMaterial(
);
}
/** 访谈槽位是否已有任意口述片段(尚无成稿章节时仍视为「进行中」)。 */
export function hasAnyMemoirDraftingActivity(
slots: MemoirState['slots'] | undefined,
): boolean {
if (!slots) return false;
return Object.keys(slots).some((stage) =>
interviewStageHasSnippetMaterial(slots, stage),
);
}
export function memoirDraftHasStarted(
slots: MemoirState['slots'] | undefined,
chapterCategory: string,

View File

@@ -1,6 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthError } from '@/core/api/types';
import { memoirApi } from './api';
import { shouldShowChapterListLoadError } from './chapter-list-response';
import { toChapterViewModels } from './mappers';
import { memoirKeys } from './query-keys';
import type { ExportPdfRequest, UpdateBookRequest } from './types';
@@ -38,15 +41,42 @@ export function useUpdateBookTitle() {
// ─── Chapters ───
export function useChapters() {
export function hasCompletedMemoirChapter(
chapters: { isEmpty: boolean }[],
): boolean {
return chapters.some((ch) => !ch.isEmpty);
}
export function useChapters(options?: { enabled?: boolean }) {
const enabled = options?.enabled ?? true;
const query = useQuery({
queryKey: memoirKeys.chapters(),
queryFn: () => memoirApi.fetchChapters(),
enabled,
retry: (failureCount, error) => {
if (error instanceof AuthError) return false;
return failureCount < 1;
},
});
const viewModels = query.data ? toChapterViewModels(query.data) : [];
const hasCompletedChapters = hasCompletedMemoirChapter(viewModels);
const isEmptyList =
query.isSuccess && viewModels.length === 0 && !hasCompletedChapters;
const showLoadError =
!query.isLoading &&
shouldShowChapterListLoadError(
query.error,
query.isSuccess,
viewModels.length,
);
return {
...query,
viewModels: query.data ? toChapterViewModels(query.data) : [],
viewModels,
hasCompletedChapters,
isEmptyList,
showLoadError,
};
}
@@ -84,10 +114,12 @@ export function useCheckCoverGeneration() {
// ─── Memoir state ───
export function useMemoirState() {
export function useMemoirState(options?: { enabled?: boolean }) {
const enabled = options?.enabled ?? true;
return useQuery({
queryKey: memoirKeys.state(),
queryFn: () => memoirApi.fetchMemoirState(),
enabled,
});
}

View File

@@ -1,10 +1,37 @@
import {
appVariant,
config,
requirePublicEnv,
shouldShowAboutBackendUrl,
type AppVariant,
} from '@/core/config';
describe('requirePublicEnv', () => {
it('throws when variable is missing or blank', () => {
const key = 'EXPO_PUBLIC_API_URL';
const previous = process.env[key];
try {
delete process.env[key];
expect(() => requirePublicEnv(key)).toThrow(/Missing EXPO_PUBLIC_API_URL/);
process.env[key] = ' ';
expect(() => requirePublicEnv(key)).toThrow(/Missing EXPO_PUBLIC_API_URL/);
} finally {
if (previous === undefined) {
process.env[key] = 'http://127.0.0.1:8000';
} else {
process.env[key] = previous;
}
}
});
});
describe('config backend URLs', () => {
it('loads API and WS from EXPO_PUBLIC_* (jest.setup defaults)', () => {
expect(config.apiBaseUrl).toBe('http://127.0.0.1:8000');
expect(config.wsBaseUrl).toBe('ws://127.0.0.1:8000');
});
});
describe('shouldShowAboutBackendUrl', () => {
it('shows backend URL for development and staging', () => {
expect(shouldShowAboutBackendUrl('development')).toBe(true);

View File

@@ -0,0 +1,71 @@
import { ApiError, AuthError, NetworkError } from '@/core/api/types';
import {
isChapterListAuthError,
isChapterListEmptySuccess,
isChapterListNotFoundError,
normalizeChapterList,
shouldShowChapterListLoadError,
} from '@/features/memoir/chapter-list-response';
import type { Chapter } from '@/features/memoir/types';
describe('normalizeChapterList', () => {
it('returns empty array for nullish or non-array payloads', () => {
expect(normalizeChapterList(null)).toEqual([]);
expect(normalizeChapterList(undefined)).toEqual([]);
expect(normalizeChapterList({ items: [] })).toEqual([]);
});
it('passes through chapter arrays', () => {
const chapters = [{ id: 'ch-1' }] as Chapter[];
expect(normalizeChapterList(chapters)).toBe(chapters);
});
});
describe('isChapterListNotFoundError', () => {
it('detects ApiError 404', () => {
expect(isChapterListNotFoundError(new ApiError('missing', 404))).toBe(true);
expect(isChapterListNotFoundError(new ApiError('bad', 500))).toBe(false);
expect(isChapterListNotFoundError(new Error('other'))).toBe(false);
});
});
describe('isChapterListEmptySuccess', () => {
it('is true only for successful empty arrays', () => {
expect(isChapterListEmptySuccess(true, [])).toBe(true);
expect(isChapterListEmptySuccess(true, [{ id: 'x' } as never])).toBe(
false,
);
expect(isChapterListEmptySuccess(false, [])).toBe(false);
});
});
describe('isChapterListAuthError', () => {
it('treats AuthError and 401/403 ApiError as auth errors', () => {
expect(isChapterListAuthError(new AuthError())).toBe(true);
expect(isChapterListAuthError(new ApiError('unauthorized', 401))).toBe(true);
expect(isChapterListAuthError(new ApiError('forbidden', 403))).toBe(true);
expect(isChapterListAuthError(new ApiError('server', 500))).toBe(false);
});
});
describe('shouldShowChapterListLoadError', () => {
it('hides load error for empty success, 404, and auth failures', () => {
expect(shouldShowChapterListLoadError(null, true, 0)).toBe(false);
expect(shouldShowChapterListLoadError(new ApiError('nope', 404), false, 0)).toBe(
false,
);
expect(shouldShowChapterListLoadError(new AuthError(), false, 0)).toBe(false);
expect(
shouldShowChapterListLoadError(new ApiError('unauthorized', 401), false, 0),
).toBe(false);
});
it('shows load error for network and server failures', () => {
expect(
shouldShowChapterListLoadError(new NetworkError('offline'), false, 0),
).toBe(true);
expect(
shouldShowChapterListLoadError(new ApiError('boom', 500), false, 0),
).toBe(true);
});
});

View File

@@ -1,5 +1,6 @@
import {
chapterCategoryToInterviewStage,
hasAnyMemoirDraftingActivity,
memoirDraftCharsRemaining,
memoirDraftHasStarted,
MIN_CHAPTER_DISPLAY_CHARS,
@@ -23,6 +24,14 @@ describe('draft-progress', () => {
).toBe('career_early');
});
test('hasAnyMemoirDraftingActivity when any stage has snippet', () => {
const slots = {
childhood: { q1: { snippet: '小时候…', status: 'filled' } },
};
expect(hasAnyMemoirDraftingActivity(slots)).toBe(true);
expect(hasAnyMemoirDraftingActivity({})).toBe(false);
});
test('memoirDraftHasStarted when interview slots have snippet', () => {
const slots = {
childhood: { place: { snippet: '老家在小城', segment_ids: [] } },

View File

@@ -0,0 +1,9 @@
/**
* Jest loads config at import time; EXPO_PUBLIC_* must be set before any @/core/config import.
*/
process.env.EXPO_PUBLIC_API_URL =
process.env.EXPO_PUBLIC_API_URL ?? 'http://127.0.0.1:8000';
process.env.EXPO_PUBLIC_WS_URL =
process.env.EXPO_PUBLIC_WS_URL ?? 'ws://127.0.0.1:8000';
process.env.EXPO_PUBLIC_APP_VARIANT =
process.env.EXPO_PUBLIC_APP_VARIANT ?? 'development';