* 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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
|
||||
clearMocks: true,
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
47
app-expo/src/features/memoir/chapter-list-response.ts
Normal file
47
app-expo/src/features/memoir/chapter-list-response.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
71
app-expo/tests/features/memoir/chapter-list-response.test.ts
Normal file
71
app-expo/tests/features/memoir/chapter-list-response.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: [] } },
|
||||
|
||||
9
app-expo/tests/jest.setup.ts
Normal file
9
app-expo/tests/jest.setup.ts
Normal 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';
|
||||
Reference in New Issue
Block a user