Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 97 additions & 22 deletions src/hooks/usePodSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,35 @@ vi.mock('../components/SolidPodContext', () => ({
useSolidPod: vi.fn(),
}))

vi.mock('../services/solidPod', () => ({
getPrimaryPodUrl: vi.fn(),
loadRdfFromPod: vi.fn(),
saveRdfToPod: vi.fn(),
AuthenticationError: class AuthenticationError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthenticationError'
vi.mock('../services/solidPod', () => {
class PodUrlUnavailableError extends Error {
constructor(public readonly reason: string) {
super(`pod url unavailable: ${reason}`)
this.name = 'PodUrlUnavailableError'
}
},
}))
}
return {
resolvePodUrl: vi.fn(),
loadRdfFromPod: vi.fn(),
saveRdfToPod: vi.fn(),
PodUrlUnavailableError,
isRetryablePodUrlFailure: (error: unknown) =>
error instanceof PodUrlUnavailableError && error.reason !== 'no-storage-declared',
AuthenticationError: class AuthenticationError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthenticationError'
}
},
}
})

import { useSolidPod } from '../components/SolidPodContext'
import { getPrimaryPodUrl, loadRdfFromPod, saveRdfToPod } from '../services/solidPod'
import { resolvePodUrl, loadRdfFromPod, saveRdfToPod } from '../services/solidPod'
import type { AppSession as Session } from '../../types/AppSession'

const mockUseSolidPod = vi.mocked(useSolidPod)
const mockGetPrimaryPodUrl = vi.mocked(getPrimaryPodUrl)
const mockResolvePodUrl = vi.mocked(resolvePodUrl)
const mockLoadRdfFromPod = vi.mocked(loadRdfFromPod)
const mockSaveRdfToPod = vi.mocked(saveRdfToPod)

Expand All @@ -51,7 +62,7 @@ function setupLoggedIn() {
login: vi.fn(),
logout: vi.fn(),
})
mockGetPrimaryPodUrl.mockResolvedValue(POD_URL)
mockResolvePodUrl.mockResolvedValue({ podUrl: POD_URL })
mockLoadRdfFromPod.mockResolvedValue(QUESTION_SET_DATA)
}

Expand All @@ -77,7 +88,7 @@ describe('usePodSync', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockGetPrimaryPodUrl.mockReset()
mockResolvePodUrl.mockReset()
mockLoadRdfFromPod.mockReset()
mockSaveRdfToPod.mockReset()
})
Expand Down Expand Up @@ -360,7 +371,7 @@ describe('usePodSync', () => {
})

expect(success).toBe(false)
expect(onSaveError).toHaveBeenCalledWith('network error')
expect(onSaveError).toHaveBeenCalledWith('network error', expect.any(Error))
})

it('does not save when not logged in and no foreign pod configured', async () => {
Expand Down Expand Up @@ -459,14 +470,78 @@ describe('usePodSync', () => {
await result.current.syncFromPod()
})

expect(onSyncError).toHaveBeenCalledWith(expect.stringContaining('Not Found'))
expect(onSyncError).toHaveBeenCalledWith(expect.stringContaining('Not Found'), expect.any(Error))
})
})

describe('unknown Pod location', () => {
it('does not report a sync when the Pod location is only temporarily unknown', async () => {
// The poll runs every few seconds; reporting a bad minute of network would
// mean an error per tick for something the next tick fixes.
setupLoggedIn()
mockResolvePodUrl.mockResolvedValue({ podUrl: null, reason: 'profile-unreachable' })
const onSyncError = vi.fn()

const { result } = renderHook(() =>
usePodSync({ pathConfig: staticPathConfig, enabled: true, onSyncError, rdf: rdfOptions })
)

await act(async () => {
await result.current.syncFromPod()
})

expect(onSyncError).not.toHaveBeenCalled()
expect(mockLoadRdfFromPod).not.toHaveBeenCalled()
})

it('reports a sync when the account declares no storage at all', async () => {
// Nothing about this one gets better on the next poll.
setupLoggedIn()
mockResolvePodUrl.mockResolvedValue({ podUrl: null, reason: 'no-storage-declared' })
const onSyncError = vi.fn()

const { result } = renderHook(() =>
usePodSync({ pathConfig: staticPathConfig, enabled: true, onSyncError, rdf: rdfOptions })
)

await act(async () => {
await result.current.syncFromPod()
})

expect(onSyncError).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ name: 'PodUrlUnavailableError', reason: 'no-storage-declared' })
)
})

it('fails a save loudly, and hands the reason to onSaveError', async () => {
// A dropped write is never silent — the local copy is the only one left.
setupLoggedIn()
mockResolvePodUrl.mockResolvedValue({ podUrl: null, reason: 'profile-unreachable' })
const onSaveError = vi.fn()

const { result } = renderHook(() =>
usePodSync({ pathConfig: staticPathConfig, enabled: true, onSaveError, rdf: rdfOptions })
)

let success: boolean | undefined
await act(async () => {
success = await result.current.saveToPod(QUESTION_SET_DATA)
})

expect(success).toBe(false)
expect(mockSaveRdfToPod).not.toHaveBeenCalled()
expect(onSaveError).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ name: 'PodUrlUnavailableError', reason: 'profile-unreachable' })
)
})
})

describe('podUrl override in pathConfig', () => {
const FOREIGN_POD_URL = 'https://alice.solidcommunity.net/'

it('syncFromPod uses pathConfig.podUrl instead of getPrimaryPodUrl when provided', async () => {
it('syncFromPod uses pathConfig.podUrl instead of resolving the signed-in Pod', async () => {
setupLoggedIn()

const { result } = renderHook(() =>
Expand All @@ -486,15 +561,15 @@ describe('usePodSync', () => {
await result.current.syncFromPod()
})

expect(mockGetPrimaryPodUrl).not.toHaveBeenCalled()
expect(mockResolvePodUrl).not.toHaveBeenCalled()
expect(mockLoadRdfFromPod).toHaveBeenCalledWith(
mockSession,
`${FOREIGN_POD_URL}pack-me-up/packing-lists/list-abc.ttl`,
expect.any(Function)
)
})

it('saveToPod uses pathConfig.podUrl instead of getPrimaryPodUrl when provided', async () => {
it('saveToPod uses pathConfig.podUrl instead of resolving the signed-in Pod', async () => {
setupLoggedIn()

const { result } = renderHook(() =>
Expand All @@ -514,15 +589,15 @@ describe('usePodSync', () => {
await result.current.saveToPod({ id: 'list-abc', name: 'Test' })
})

expect(mockGetPrimaryPodUrl).not.toHaveBeenCalled()
expect(mockResolvePodUrl).not.toHaveBeenCalled()
expect(mockSaveRdfToPod).toHaveBeenCalledWith(
expect.objectContaining({
fileUrl: `${FOREIGN_POD_URL}pack-me-up/packing-lists/list-abc.ttl`,
})
)
})

it('falls back to getPrimaryPodUrl when pathConfig.podUrl is absent', async () => {
it('resolves the signed-in Pod when pathConfig.podUrl is absent', async () => {
setupLoggedIn()

const { result } = renderHook(() =>
Expand All @@ -536,7 +611,7 @@ describe('usePodSync', () => {
await result.current.syncFromPod()
})

expect(mockGetPrimaryPodUrl).toHaveBeenCalled()
expect(mockResolvePodUrl).toHaveBeenCalled()
})
})
})
55 changes: 36 additions & 19 deletions src/hooks/usePodSync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import type { SolidDataset } from '@inrupt/solid-client';
import { useSolidPod } from '../components/SolidPodContext';
import { getPrimaryPodUrl, loadRdfFromPod, saveRdfToPod, AuthenticationError } from '../services/solidPod';
import { resolvePodUrl, loadRdfFromPod, saveRdfToPod, AuthenticationError, PodUrlUnavailableError, isRetryablePodUrlFailure } from '../services/solidPod';
import type { AppSession } from '../types/AppSession';
import { profile, profileEvent } from '../utils/profiling';

/**
Expand All @@ -26,7 +27,7 @@ export interface PodPathConfig {
resourceId?: string | null;

/**
* Override the pod URL — bypasses getPrimaryPodUrl when set.
* Override the pod URL — bypasses resolvePodUrl when set.
* Use this to sync with a foreign user's pod (e.g. a shared list).
*/
podUrl?: string;
Expand Down Expand Up @@ -63,19 +64,22 @@ export interface PodSyncOptions<T> {
onSyncSuccess?: (data: T) => void;

/**
* Callback when sync from Pod fails
* Callback when sync from Pod fails.
* `cause` is the error itself, so callers can tell a Pod we couldn't reach
* this second from a real fault worth reporting.
*/
onSyncError?: (error: string) => void;
onSyncError?: (error: string, cause?: unknown) => void;

/**
* Callback when save to Pod succeeds
*/
onSaveSuccess?: () => void;

/**
* Callback when save to Pod fails
* Callback when save to Pod fails.
* `cause` is the error itself — see `onSyncError`.
*/
onSaveError?: (error: string) => void;
onSaveError?: (error: string, cause?: unknown) => void;

/**
* Whether sync is enabled
Expand All @@ -91,6 +95,20 @@ export interface PodSyncState<T> {
syncFromPod: () => Promise<void>;
}

/**
* Where this user's Pod lives, or a `PodUrlUnavailableError` saying why we don't
* know. Throwing rather than returning null keeps the reason attached all the
* way to the callbacks, which is what lets a momentary blip stay out of the
* user's face and out of Sentry.
*/
async function requirePodUrl(session: AppSession | null): Promise<string> {
const { podUrl, reason } = await resolvePodUrl(session);
if (!podUrl) {
throw new PodUrlUnavailableError(reason ?? 'no-session');
}
return podUrl;
}

/**
* Generic hook for automatic synchronization with Solid Pod
* Polls the pod at regular intervals and provides manual sync functions
Expand Down Expand Up @@ -219,11 +237,7 @@ export function usePodSync<T>(options: PodSyncOptions<T>): PodSyncState<T> {
setError(null);

try {
const podUrl = pathConfigRef.current.podUrl ?? await getPrimaryPodUrl(session);

if (!podUrl) {
throw new Error('No pod URL found');
}
const podUrl = pathConfigRef.current.podUrl ?? await requirePodUrl(session);

const fileUrl = getFileUrl(podUrl);

Expand All @@ -244,15 +258,22 @@ export function usePodSync<T>(options: PodSyncOptions<T>): PodSyncState<T> {
// 404 on own pod = file not yet created (expected) → silent
// 404 on foreign pod (pathConfig.podUrl set) = file missing or access denied → report
const isSilentMiss = statusCode === 404 && !pathConfigRef.current.podUrl
if (!isSilentMiss) {
// Not knowing where the Pod is yet is not a failed sync, it is a sync that
// hasn't started. This poll runs every few seconds, so the next one picks
// it up — reporting it would mean an error per tick for a bad minute of
// network. An account that declares no storage at all is not retryable and
// still reports.
if (isRetryablePodUrlFailure(err)) {
console.warn('Skipping sync from Pod:', (err as Error).message)
} else if (!isSilentMiss) {
// Authentication errors use their own message
const errorMessage = err instanceof AuthenticationError
? err.message
: (err instanceof Error ? err.message : 'Failed to sync from Pod');
setError(errorMessage);

if (onSyncErrorRef.current) {
onSyncErrorRef.current(errorMessage);
onSyncErrorRef.current(errorMessage, err);
}
}
} finally {
Expand All @@ -273,11 +294,7 @@ export function usePodSync<T>(options: PodSyncOptions<T>): PodSyncState<T> {
profileEvent('podSync.saveToPod.start');

try {
const podUrl = pathConfigRef.current.podUrl ?? await getPrimaryPodUrl(session!);

if (!podUrl) {
throw new Error('No pod URL found');
}
const podUrl = pathConfigRef.current.podUrl ?? await requirePodUrl(session);

const fileUrl = getFileUrl(podUrl);

Expand Down Expand Up @@ -307,7 +324,7 @@ export function usePodSync<T>(options: PodSyncOptions<T>): PodSyncState<T> {
setError(errorMessage);

if (onSaveErrorRef.current) {
onSaveErrorRef.current(errorMessage);
onSaveErrorRef.current(errorMessage, err);
}

return false;
Expand Down
3 changes: 3 additions & 0 deletions src/pages/sharing-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ vi.mock('../services/solidPod', () => ({
getCollaborators: vi.fn(() => Promise.resolve([])),
isPubliclyAccessible: vi.fn(() => Promise.resolve(false)),
getPrimaryPodUrl: vi.fn(() => Promise.resolve('https://pod.example.com/')),
resolvePodUrl: vi.fn(() => Promise.resolve({ podUrl: 'https://pod.example.com/' })),
isRetryablePodUrlFailure: () => false,
PodUrlUnavailableError: class PodUrlUnavailableError extends Error {},
getPodOwnerName: vi.fn(() => Promise.resolve(null)),
friendlyPodName: vi.fn((url: string) => url),
resolveOwnerDisplayName: vi.fn((foafName: string | null | undefined, ownerWebId: string | null | undefined, podUrl: string) => foafName ?? ownerWebId ?? podUrl),
Expand Down
Loading
Loading