test: add integrations, cache, cron, and telemetry tests

- New integrations.test.ts: 13 tests covering CRUD, token generation,
  token lookup, regeneration, and multi-user isolation
- New cache.test.ts: 3 tests for purgeMetadataCache (shell title
  deletion, fully-fetched title preservation, empty case)
- New cron.test.ts: 5 tests for cron run lifecycle (start, complete,
  fail with Error and string)
- New telemetry.test.ts: 7 tests for isTelemetryEnabled toggle and
  performTelemetryReport (disabled skip, enabled send, 24h interval
  throttle, interval expiry, fetch failure resilience)

345 → 386 tests passing across 27 files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 12:39:04 -04:00
co-authored by Claude Opus 4.6
parent 554271a888
commit 373a5d3caf
16 changed files with 402 additions and 61 deletions
+7
View File
@@ -1,5 +1,12 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"jsPlugins": ["@tanstack/eslint-plugin-query"],
"rules": {
"@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/no-rest-destructuring": "warn",
"@tanstack/query/stable-query-client": "error",
"@tanstack/query/no-unstable-deps": "error"
},
"ignorePatterns": ["node_modules", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-types.d.ts"]
}
@@ -111,7 +111,7 @@ export default function SettingsScreen() {
}),
);
const uploadAvatar = useMutation(
const { mutate: uploadAvatarFile, isPending: isUploadingAvatar } = useMutation(
orpc.account.uploadAvatar.mutationOptions({
onSuccess: () => {
toast.success(t`Profile picture updated`);
@@ -149,8 +149,8 @@ export default function SettingsScreen() {
const file = new File([blob], asset.fileName ?? "avatar.jpg", {
type: asset.mimeType ?? "image/jpeg",
});
uploadAvatar.mutate(file);
}, [uploadAvatar]);
uploadAvatarFile(file);
}, [uploadAvatarFile]);
const hasAvatarImage = !!session?.user?.image;
@@ -235,7 +235,7 @@ export default function SettingsScreen() {
hitSlop={8}
>
<View className="bg-secondary size-11 overflow-hidden rounded-full">
{uploadAvatar.isPending ? (
{isUploadingAvatar ? (
<View className="flex-1 items-center justify-center">
<Spinner size="sm" colorClassName="accent-primary" />
</View>
@@ -280,7 +280,7 @@ export default function SettingsScreen() {
hitSlop={8}
>
<View className="bg-secondary size-11 overflow-hidden rounded-full">
{uploadAvatar.isPending ? (
{isUploadingAvatar ? (
<View className="flex-1 items-center justify-center">
<Spinner size="sm" colorClassName="accent-primary" />
</View>
@@ -90,7 +90,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
}),
);
const deleteMutation = useMutation(
const { mutate: deleteIntegration } = useMutation(
orpc.integrations.delete.mutationOptions({
onSuccess: () => {
toast.success(t`${label} disconnected`);
@@ -100,7 +100,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
}),
);
const regenerateMutation = useMutation(
const { mutate: regenerateToken, isPending: isRegenerating } = useMutation(
orpc.integrations.regenerateToken.mutationOptions({
onSuccess: () => {
toast.success(t`${label} URL regenerated`);
@@ -136,11 +136,11 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
{
text: t`Regenerate`,
style: "destructive",
onPress: () => regenerateMutation.mutate({ provider: providerInput }),
onPress: () => regenerateToken({ provider: providerInput }),
},
],
);
}, [label, providerInput, regenerateMutation, t]);
}, [label, providerInput, regenerateToken, t]);
const handleDisconnect = useCallback(() => {
Alert.alert(
@@ -151,11 +151,11 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
{
text: t`Disconnect`,
style: "destructive",
onPress: () => deleteMutation.mutate({ provider: providerInput }),
onPress: () => deleteIntegration({ provider: providerInput }),
},
],
);
}, [label, providerInput, deleteMutation, t]);
}, [label, providerInput, deleteIntegration, t]);
const Icon = config.icon;
@@ -249,7 +249,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
<View className="flex-row gap-2">
<Pressable
onPress={handleRegenerate}
disabled={regenerateMutation.isPending}
disabled={isRegenerating}
className="bg-secondary flex-1 flex-row items-center justify-center gap-1.5 rounded-lg py-2.5 active:opacity-80"
>
<ScaledIcon icon={IconRefresh} size={14} color={mutedFgColor} />
+9 -1
View File
@@ -1,4 +1,12 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"]
"extends": ["../../.oxlintrc.json"],
"jsPlugins": ["@tanstack/eslint-plugin-query", "@tanstack/eslint-plugin-router"],
"rules": {
"@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/no-rest-destructuring": "warn",
"@tanstack/query/stable-query-client": "error",
"@tanstack/query/no-unstable-deps": "error",
"@tanstack/router/create-route-property-order": "warn"
}
}
@@ -124,7 +124,11 @@ export function BackupScheduleSection() {
return t`Next backup ${distance}`;
}
const updateScheduleMutation = useMutation(
const {
mutate: updateSchedule,
isPending: isUpdatingSchedule,
variables: updateScheduleVars,
} = useMutation(
orpc.admin.backups.updateSchedule.mutationOptions({
onMutate: (input) => {
const previous = { ...current };
@@ -150,14 +154,12 @@ export function BackupScheduleSection() {
}),
);
const togglingSchedule =
updateScheduleMutation.isPending && updateScheduleMutation.variables?.enabled !== undefined;
const savingSchedule =
updateScheduleMutation.isPending && updateScheduleMutation.variables?.frequency !== undefined;
const togglingSchedule = isUpdatingSchedule && updateScheduleVars?.enabled !== undefined;
const savingSchedule = isUpdatingSchedule && updateScheduleVars?.frequency !== undefined;
const toggleScheduled = useCallback(
(checked: boolean) => {
updateScheduleMutation.mutate(
updateSchedule(
{ enabled: checked },
{
onSuccess: () =>
@@ -165,19 +167,19 @@ export function BackupScheduleSection() {
},
);
},
[updateScheduleMutation, t],
[updateSchedule, t],
);
const changeMaxRetention = useCallback(
(value: number) => {
updateScheduleMutation.mutate({ maxRetention: value });
updateSchedule({ maxRetention: value });
},
[updateScheduleMutation],
[updateSchedule],
);
const changeSchedule = useCallback(
(newFrequency: BackupFrequency, newTime: string, newDow = current.dow) => {
updateScheduleMutation.mutate(
updateSchedule(
{
frequency: newFrequency,
time: newTime,
@@ -186,7 +188,7 @@ export function BackupScheduleSection() {
{ onSuccess: () => toast.success(t`Schedule updated`) },
);
},
[updateScheduleMutation, current.dow, t],
[updateSchedule, current.dow, t],
);
if (isPending) {
@@ -40,15 +40,15 @@ export function useTitleActions() {
[queryClient, userInfoKey],
);
const batchWatchMutation = useMutation(orpc.episodes.batchWatch.mutationOptions());
const updateStatusMutation = useMutation(orpc.titles.updateStatus.mutationOptions());
const updateRatingMutation = useMutation(orpc.titles.updateRating.mutationOptions());
const watchMovieMutation = useMutation(orpc.titles.watchMovie.mutationOptions());
const unwatchEpMutation = useMutation(orpc.episodes.unwatch.mutationOptions());
const watchEpMutation = useMutation(orpc.episodes.watch.mutationOptions());
const watchSeasonMutation = useMutation(orpc.seasons.watch.mutationOptions());
const unwatchSeasonMutation = useMutation(orpc.seasons.unwatch.mutationOptions());
const watchAllMutation = useMutation(orpc.titles.watchAll.mutationOptions());
const { mutateAsync: batchWatch } = useMutation(orpc.episodes.batchWatch.mutationOptions());
const { mutateAsync: updateStatus } = useMutation(orpc.titles.updateStatus.mutationOptions());
const { mutateAsync: updateRating } = useMutation(orpc.titles.updateRating.mutationOptions());
const { mutateAsync: watchMovie } = useMutation(orpc.titles.watchMovie.mutationOptions());
const { mutateAsync: unwatchEp } = useMutation(orpc.episodes.unwatch.mutationOptions());
const { mutateAsync: watchEp } = useMutation(orpc.episodes.watch.mutationOptions());
const { mutateAsync: watchSeason } = useMutation(orpc.seasons.watch.mutationOptions());
const { mutateAsync: unwatchSeason } = useMutation(orpc.seasons.unwatch.mutationOptions());
const { mutateAsync: watchAll } = useMutation(orpc.titles.watchAll.mutationOptions());
const catchUp = useCallback(
async (episodeIds: string[]) => {
@@ -63,7 +63,7 @@ export function useTitleActions() {
}));
try {
await batchWatchMutation.mutateAsync({ episodeIds });
await batchWatch({ episodeIds });
await queryClient.invalidateQueries({ queryKey: userInfoKey });
const count = episodeIds.length;
toast.success(
@@ -78,7 +78,7 @@ export function useTitleActions() {
toast.error(t`Failed to catch up`);
}
},
[getUserInfo, setUserInfo, batchWatchMutation, queryClient, userInfoKey, t],
[getUserInfo, setUserInfo, batchWatch, queryClient, userInfoKey, t],
);
const handleStatusChange = useCallback(
@@ -89,7 +89,7 @@ export function useTitleActions() {
status: status ? "in_watchlist" : null,
}));
try {
await updateStatusMutation.mutateAsync({
await updateStatus({
id: titleId,
status: status ? "watchlist" : null,
});
@@ -99,7 +99,7 @@ export function useTitleActions() {
toast.error(t`Failed to update status`);
}
},
[getUserInfo, setUserInfo, titleId, updateStatusMutation, t],
[getUserInfo, setUserInfo, titleId, updateStatus, t],
);
const handleRating = useCallback(
@@ -107,7 +107,7 @@ export function useTitleActions() {
const prevRating = getUserInfo().rating;
setUserInfo((old) => ({ ...old, rating: ratingStars }));
try {
await updateRatingMutation.mutateAsync({
await updateRating({
id: titleId,
stars: ratingStars,
});
@@ -121,20 +121,20 @@ export function useTitleActions() {
toast.error(t`Failed to update rating`);
}
},
[getUserInfo, setUserInfo, titleId, updateRatingMutation, t],
[getUserInfo, setUserInfo, titleId, updateRating, t],
);
const handleWatchMovie = useCallback(async () => {
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({ ...old, status: "completed" }));
try {
await watchMovieMutation.mutateAsync({ id: titleId });
await watchMovie({ id: titleId });
toast.success(t`Marked "${titleName}" as watched`);
} catch {
setUserInfo((old) => ({ ...old, status: prevStatus }));
toast.error(t`Failed to mark as watched`);
}
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation, t]);
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovie, t]);
const handleWatchEpisode = useCallback(
async (episodeId: string, seasonNum: number, epNum: number, isWatched: boolean) => {
@@ -151,7 +151,7 @@ export function useTitleActions() {
}));
try {
await unwatchEpMutation.mutateAsync({ id: episodeId });
await unwatchEp({ id: episodeId });
toast.success(t`Unwatched S${seasonNum} E${epNum}`);
} catch {
setUserInfo((old) => ({
@@ -176,7 +176,7 @@ export function useTitleActions() {
}));
try {
await watchEpMutation.mutateAsync({ id: episodeId });
await watchEp({ id: episodeId });
const watchedSet = new Set(getUserInfo().episodeWatches);
const previousUnwatched: string[] = [];
@@ -218,16 +218,7 @@ export function useTitleActions() {
setWatchingEp(null);
},
[
getUserInfo,
setUserInfo,
setWatchingEp,
seasons,
catchUp,
unwatchEpMutation,
watchEpMutation,
t,
],
[getUserInfo, setUserInfo, setWatchingEp, seasons, catchUp, unwatchEp, watchEp, t],
);
const handleMarkSeason = useCallback(
@@ -249,7 +240,7 @@ export function useTitleActions() {
}));
try {
await watchSeasonMutation.mutateAsync({ id: season.id });
await watchSeason({ id: season.id });
await queryClient.invalidateQueries({ queryKey: userInfoKey });
const currentWatchSet = new Set(getUserInfo().episodeWatches);
@@ -288,7 +279,7 @@ export function useTitleActions() {
toast.error(t`Failed to mark some episodes`);
}
},
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation, queryClient, userInfoKey, t],
[getUserInfo, setUserInfo, seasons, catchUp, watchSeason, queryClient, userInfoKey, t],
);
const handleUnmarkSeason = useCallback(
@@ -303,7 +294,7 @@ export function useTitleActions() {
}));
try {
await unwatchSeasonMutation.mutateAsync({ id: season.id });
await unwatchSeason({ id: season.id });
const seasonNumber = season.seasonNumber;
const seasonLabel = season.name ?? t`Season ${seasonNumber}`;
toast.success(t`Unwatched all of ${seasonLabel}`);
@@ -316,7 +307,7 @@ export function useTitleActions() {
toast.error(t`Failed to unmark some episodes`);
}
},
[getUserInfo, setUserInfo, unwatchSeasonMutation, t],
[getUserInfo, setUserInfo, unwatchSeason, t],
);
const handleMarkAllWatched = useCallback(async () => {
@@ -329,7 +320,7 @@ export function useTitleActions() {
status: old.status ?? "watching",
}));
try {
await watchAllMutation.mutateAsync({ id: titleId });
await watchAll({ id: titleId });
// Refresh to get server-derived display status (caught_up / completed)
await queryClient.invalidateQueries({ queryKey: userInfoKey });
toast.success(t`Marked all episodes as watched`);
@@ -341,7 +332,7 @@ export function useTitleActions() {
}));
toast.error(t`Failed to mark all episodes as watched`);
}
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation, queryClient, userInfoKey, t]);
}, [getUserInfo, setUserInfo, seasons, titleId, watchAll, queryClient, userInfoKey, t]);
return {
handleStatusChange,