mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react", "import", "unicorn", "vitest"],
|
||||
"jsPlugins": ["eslint-plugin-lingui"],
|
||||
"env": {
|
||||
"builtin": true
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
"@lingui/cli": "catalog:",
|
||||
"@lingui/conf": "catalog:",
|
||||
"@lingui/format-po": "catalog:",
|
||||
"@tanstack/eslint-plugin-query": "5.94.5",
|
||||
"@tanstack/eslint-plugin-router": "1.161.6",
|
||||
"@vitest/browser": "catalog:",
|
||||
"@vitest/browser-playwright": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint-plugin-drizzle": "0.2.3",
|
||||
"eslint-plugin-lingui": "0.11.0",
|
||||
"oxfmt": "0.41.0",
|
||||
"oxlint": "1.56.0",
|
||||
@@ -1321,6 +1324,10 @@
|
||||
|
||||
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.6.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.6", "@tanstack/devtools-event-bus": "0.4.1", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-h0r0ct7zlrgjkhmn4QW6wRjgUXd4JMs+r7gtx+BXo9f5H9Y+jtUdtvC0rnZcPto6gw/9yMUq7yOmMK5qDWRExg=="],
|
||||
|
||||
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.94.5", "", { "dependencies": { "@typescript-eslint/utils": "^8.48.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": "^5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-r3yV8xItL6YuyF3oRYL/lVudq7oONceJGy+suFoicPwUAmWqHJ1yrsXGs3WAs7MSPEqwkxcOTseUn6VzLLJmew=="],
|
||||
|
||||
"@tanstack/eslint-plugin-router": ["@tanstack/eslint-plugin-router@1.161.6", "", { "dependencies": { "@typescript-eslint/utils": "^8.23.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-aHln6x6mhtQmrbwMI0lmnMh7t9nEvrJrFOl1vGtQknEKdgKEEqSh9a9/MZKNm3kENaHotoqpRcfbE/or9aAYfQ=="],
|
||||
|
||||
"@tanstack/form-core": ["@tanstack/form-core@1.28.5", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-8lYnduHHfP6uaXF9+2OLnh3Fo27tH4TdtekWLG2b/Bp26ynbrWG6L4qhBgEb7VcvTpJw/RjvJF/JyFhZkG3pfQ=="],
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="],
|
||||
@@ -1841,6 +1848,8 @@
|
||||
|
||||
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
|
||||
|
||||
"eslint-plugin-drizzle": ["eslint-plugin-drizzle@0.2.3", "", { "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-BO+ymHo33IUNoJlC0rbd7HP9EwwpW4VIp49R/tWQF/d2E1K2kgTf0tCXT0v9MSiBr6gGR1LtPwMLapTKEWSg9A=="],
|
||||
|
||||
"eslint-plugin-lingui": ["eslint-plugin-lingui@0.11.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.0.0", "micromatch": "^4.0.0" }, "peerDependencies": { "eslint": "^8.37.0 || ^9.0.0" } }, "sha512-O2Ixoapt5fa4VKZJgXhVwb6BHnzByIUDNMfZOhHWGMYk40GfGCho4MUfspLVrHAFLimgBPKXtCcJ8GC4YNZmfg=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
@@ -59,9 +59,12 @@
|
||||
"@lingui/cli": "catalog:",
|
||||
"@lingui/conf": "catalog:",
|
||||
"@lingui/format-po": "catalog:",
|
||||
"@tanstack/eslint-plugin-query": "5.94.5",
|
||||
"@tanstack/eslint-plugin-router": "1.161.6",
|
||||
"@vitest/browser": "catalog:",
|
||||
"@vitest/browser-playwright": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint-plugin-drizzle": "0.2.3",
|
||||
"eslint-plugin-lingui": "0.11.0",
|
||||
"oxfmt": "0.41.0",
|
||||
"oxlint": "1.56.0",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/test/db";
|
||||
|
||||
import { purgeMetadataCache } from "../src/cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
describe("purgeMetadataCache", () => {
|
||||
test("deletes shell titles not in any user library", () => {
|
||||
// Shell title (lastFetchedAt = null, no user status)
|
||||
insertTitle({ id: "shell-1", tmdbId: 100, title: "Shell Movie" });
|
||||
|
||||
const result = purgeMetadataCache();
|
||||
expect(result.deletedTitles).toBe(1);
|
||||
|
||||
const remaining = testDb.select().from(titles).all();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("preserves fully-fetched titles", () => {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
id: "fetched-1",
|
||||
tmdbId: 100,
|
||||
type: "movie",
|
||||
title: "Fetched Movie",
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
const result = purgeMetadataCache();
|
||||
expect(result.deletedTitles).toBe(0);
|
||||
|
||||
const remaining = testDb.select().from(titles).all();
|
||||
expect(remaining).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("returns zero counts when nothing to purge", () => {
|
||||
const result = purgeMetadataCache();
|
||||
expect(result.deletedTitles).toBe(0);
|
||||
expect(result.deletedPersons).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { cronRuns } from "@sofa/db/schema";
|
||||
import { clearAllTables, eq, testDb } from "@sofa/test/db";
|
||||
|
||||
import { completeCronRun, failCronRun, startCronRun } from "../src/cron";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
describe("startCronRun", () => {
|
||||
test("inserts a cron run record", () => {
|
||||
const run = startCronRun("metadata-refresh");
|
||||
expect(run.id).toBeDefined();
|
||||
expect(run.jobName).toBe("metadata-refresh");
|
||||
|
||||
const row = testDb.select().from(cronRuns).where(eq(cronRuns.id, run.id)).get();
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.status).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeCronRun", () => {
|
||||
test("marks a run as successful with duration", () => {
|
||||
const run = startCronRun("test-job");
|
||||
completeCronRun(run.id, 1500);
|
||||
|
||||
const row = testDb.select().from(cronRuns).where(eq(cronRuns.id, run.id)).get();
|
||||
expect(row?.status).toBe("success");
|
||||
expect(row?.durationMs).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failCronRun", () => {
|
||||
test("marks a run as failed with error message", () => {
|
||||
const run = startCronRun("test-job");
|
||||
failCronRun(run.id, 500, new Error("Something broke"));
|
||||
|
||||
const row = testDb.select().from(cronRuns).where(eq(cronRuns.id, run.id)).get();
|
||||
expect(row?.status).toBe("error");
|
||||
expect(row?.durationMs).toBe(500);
|
||||
expect(row?.errorMessage).toBe("Something broke");
|
||||
});
|
||||
|
||||
test("handles non-Error objects", () => {
|
||||
const run = startCronRun("test-job");
|
||||
failCronRun(run.id, 100, "string error");
|
||||
|
||||
const row = testDb.select().from(cronRuns).where(eq(cronRuns.id, run.id)).get();
|
||||
expect(row?.errorMessage).toBe("string error");
|
||||
});
|
||||
});
|
||||
@@ -391,7 +391,7 @@ describe("processImportJob — state transitions", () => {
|
||||
createdAt,
|
||||
})
|
||||
.run(),
|
||||
).toThrow();
|
||||
).toThrow("UNIQUE constraint");
|
||||
|
||||
testDb.update(importJobs).set({ status: "success" }).where(eq(importJobs.id, "job-1")).run();
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { clearAllTables, insertUser } from "@sofa/test/db";
|
||||
|
||||
import {
|
||||
createOrUpdateIntegration,
|
||||
deleteIntegration,
|
||||
findIntegrationByToken,
|
||||
listUserIntegrations,
|
||||
regenerateToken,
|
||||
} from "../src/integrations";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
insertUser("user-1");
|
||||
});
|
||||
|
||||
describe("createOrUpdateIntegration", () => {
|
||||
test("creates a new webhook integration", () => {
|
||||
const result = createOrUpdateIntegration("user-1", "plex");
|
||||
expect(result.provider).toBe("plex");
|
||||
expect(result.type).toBe("webhook");
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.token).toHaveLength(64); // 32 bytes hex
|
||||
});
|
||||
|
||||
test("creates a list integration for sonarr", () => {
|
||||
const result = createOrUpdateIntegration("user-1", "sonarr");
|
||||
expect(result.type).toBe("list");
|
||||
});
|
||||
|
||||
test("creates a list integration for radarr", () => {
|
||||
const result = createOrUpdateIntegration("user-1", "radarr");
|
||||
expect(result.type).toBe("list");
|
||||
});
|
||||
|
||||
test("returns existing integration without duplicating", () => {
|
||||
const first = createOrUpdateIntegration("user-1", "plex");
|
||||
const second = createOrUpdateIntegration("user-1", "plex");
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.token).toBe(first.token);
|
||||
});
|
||||
|
||||
test("updates enabled state on existing integration", () => {
|
||||
createOrUpdateIntegration("user-1", "plex", true);
|
||||
const updated = createOrUpdateIntegration("user-1", "plex", false);
|
||||
expect(updated.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("creates with enabled=false when specified", () => {
|
||||
const result = createOrUpdateIntegration("user-1", "jellyfin", false);
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listUserIntegrations", () => {
|
||||
test("returns empty list for user with no integrations", () => {
|
||||
const result = listUserIntegrations("user-1");
|
||||
expect(result.integrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("returns all integrations for user", () => {
|
||||
createOrUpdateIntegration("user-1", "plex");
|
||||
createOrUpdateIntegration("user-1", "jellyfin");
|
||||
|
||||
const result = listUserIntegrations("user-1");
|
||||
expect(result.integrations).toHaveLength(2);
|
||||
const providers = result.integrations.map((i) => i.provider).sort();
|
||||
expect(providers).toEqual(["jellyfin", "plex"]);
|
||||
});
|
||||
|
||||
test("does not return other users integrations", () => {
|
||||
insertUser("user-2");
|
||||
createOrUpdateIntegration("user-1", "plex");
|
||||
createOrUpdateIntegration("user-2", "jellyfin");
|
||||
|
||||
const result = listUserIntegrations("user-1");
|
||||
expect(result.integrations).toHaveLength(1);
|
||||
expect(result.integrations[0].provider).toBe("plex");
|
||||
});
|
||||
|
||||
test("serializes dates as ISO strings", () => {
|
||||
createOrUpdateIntegration("user-1", "plex");
|
||||
const result = listUserIntegrations("user-1");
|
||||
expect(result.integrations[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteIntegration", () => {
|
||||
test("removes an integration", () => {
|
||||
createOrUpdateIntegration("user-1", "plex");
|
||||
deleteIntegration("user-1", "plex");
|
||||
|
||||
const result = listUserIntegrations("user-1");
|
||||
expect(result.integrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("does not throw for nonexistent integration", () => {
|
||||
expect(() => deleteIntegration("user-1", "nonexistent")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findIntegrationByToken", () => {
|
||||
test("finds integration by token", () => {
|
||||
const created = createOrUpdateIntegration("user-1", "plex");
|
||||
const found = findIntegrationByToken(created.token);
|
||||
expect(found?.id).toBe(created.id);
|
||||
});
|
||||
|
||||
test("returns undefined for invalid token", () => {
|
||||
expect(findIntegrationByToken("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("regenerateToken", () => {
|
||||
test("generates a new token for existing integration", () => {
|
||||
const created = createOrUpdateIntegration("user-1", "plex");
|
||||
const result = regenerateToken("user-1", "plex");
|
||||
expect(result?.token).not.toBe(created.token);
|
||||
expect(result?.token).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { clearAllTables, insertUser } from "@sofa/test/db";
|
||||
|
||||
import { setSetting } from "../src/settings";
|
||||
import { isTelemetryEnabled, performTelemetryReport } from "../src/telemetry";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
insertUser("user-1");
|
||||
});
|
||||
|
||||
describe("isTelemetryEnabled", () => {
|
||||
test("returns false when setting is not set", () => {
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when setting is 'true'", () => {
|
||||
setSetting("telemetryEnabled", "true");
|
||||
expect(isTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when setting is 'false'", () => {
|
||||
setSetting("telemetryEnabled", "false");
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performTelemetryReport", () => {
|
||||
test("skips when telemetry is disabled", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
await performTelemetryReport();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sends report when telemetry is enabled", async () => {
|
||||
setSetting("telemetryEnabled", "true");
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
(async () => new Response(null, { status: 200 })) as unknown as typeof fetch,
|
||||
);
|
||||
|
||||
await performTelemetryReport();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
const url = String(call[0]);
|
||||
expect(url).toContain("/v1/telemetry");
|
||||
|
||||
const init = call[1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toHaveProperty("instanceId");
|
||||
expect(body).toHaveProperty("version");
|
||||
expect(body).toHaveProperty("users");
|
||||
expect(body).toHaveProperty("titles");
|
||||
expect(body).toHaveProperty("features");
|
||||
});
|
||||
|
||||
test("respects 24-hour report interval", async () => {
|
||||
setSetting("telemetryEnabled", "true");
|
||||
setSetting("telemetryLastReportedAt", new Date().toISOString());
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
await performTelemetryReport();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports again after interval expires", async () => {
|
||||
setSetting("telemetryEnabled", "true");
|
||||
const oldDate = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
setSetting("telemetryLastReportedAt", oldDate);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
(async () => new Response(null, { status: 200 })) as unknown as typeof fetch,
|
||||
);
|
||||
|
||||
await performTelemetryReport();
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("does not throw on fetch failure", async () => {
|
||||
setSetting("telemetryEnabled", "true");
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
(async () => new Response(null, { status: 500 })) as unknown as typeof fetch,
|
||||
);
|
||||
|
||||
await expect(performTelemetryReport()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,7 @@ describe("removeTitleStatus", () => {
|
||||
insertTitle();
|
||||
// Should not throw
|
||||
removeTitleStatus("user-1", "title-1");
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": ["eslint-plugin-drizzle"],
|
||||
"rules": {
|
||||
"drizzle/enforce-delete-with-where": ["error", { "drizzleObjectName": ["db", "tx"] }],
|
||||
"drizzle/enforce-update-with-where": ["error", { "drizzleObjectName": ["db", "tx"] }]
|
||||
},
|
||||
"ignorePatterns": ["node_modules", "dist", "build", "drizzle"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user