test: raise coverage to 99 percent

This commit is contained in:
xixu-me committed 2026-04-09 00:43:58 +08:00
1 parent 42bd6e2b3a
commit 33c73cf620
14 files changed
+646 -73

No files matched your search

+2 -1
View File
@@ -149,7 +149,8 @@ jobs:
fi fi
- name: Run API tests with coverage - name: Run API tests with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic shell: bash
run: go test $(go list ./... | grep -v '/internal/testutil$') -coverprofile=coverage.out -covermode=atomic
- name: Print API coverage summary - name: Print API coverage summary
run: go tool cover -func=coverage.out run: go tool cover -func=coverage.out
@@ -1,8 +1,14 @@
package repo package repo
import ( import (
"context"
"errors"
"testing" "testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -15,3 +21,124 @@ func TestNewPostgresRepositoryWrapsPoolBackedDB(t *testing.T) {
require.True(t, ok) require.True(t, ok)
require.Nil(t, wrapped.pool) require.Nil(t, wrapped.pool)
} }
type wrapperStubRow struct {
err error
}
func (row wrapperStubRow) Scan(dest ...any) error {
_ = dest
return row.err
}
type wrapperStubTx struct {
commitErr error
rollbackErr error
execErr error
queryRow pgx.Row
execSQL string
queryRowSQL string
execArgs []any
queryRowArgs []any
}
func (tx *wrapperStubTx) Begin(context.Context) (pgx.Tx, error) {
return tx, nil
}
func (tx *wrapperStubTx) Commit(context.Context) error {
return tx.commitErr
}
func (tx *wrapperStubTx) Rollback(context.Context) error {
return tx.rollbackErr
}
func (tx *wrapperStubTx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) {
panic("unexpected CopyFrom call")
}
func (tx *wrapperStubTx) SendBatch(context.Context, *pgx.Batch) pgx.BatchResults {
panic("unexpected SendBatch call")
}
func (tx *wrapperStubTx) LargeObjects() pgx.LargeObjects {
return pgx.LargeObjects{}
}
func (tx *wrapperStubTx) Prepare(context.Context, string, string) (*pgconn.StatementDescription, error) {
panic("unexpected Prepare call")
}
func (tx *wrapperStubTx) Exec(_ context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) {
tx.execSQL = sql
tx.execArgs = arguments
return pgconn.CommandTag{}, tx.execErr
}
func (tx *wrapperStubTx) Query(context.Context, string, ...any) (pgx.Rows, error) {
panic("unexpected Query call")
}
func (tx *wrapperStubTx) QueryRow(_ context.Context, sql string, args ...any) pgx.Row {
tx.queryRowSQL = sql
tx.queryRowArgs = args
return tx.queryRow
}
func (tx *wrapperStubTx) Conn() *pgx.Conn {
return nil
}
func TestPgxTxDelegatesToTheUnderlyingTransaction(t *testing.T) {
t.Parallel()
ctx := context.Background()
row := wrapperStubRow{err: errors.New("scan failed")}
tx := &wrapperStubTx{
commitErr: errors.New("commit failed"),
rollbackErr: errors.New("rollback failed"),
execErr: errors.New("exec failed"),
queryRow: row,
}
wrapped := pgxTx{tx: tx}
require.ErrorIs(t, wrapped.Commit(ctx), tx.commitErr)
require.ErrorIs(t, wrapped.Exec(ctx, "select 1", "arg"), tx.execErr)
require.Equal(t, "select 1", tx.execSQL)
require.Equal(t, []any{"arg"}, tx.execArgs)
require.Equal(t, row, wrapped.QueryRow(ctx, "select 2", 42))
require.Equal(t, "select 2", tx.queryRowSQL)
require.Equal(t, []any{42}, tx.queryRowArgs)
require.ErrorIs(t, wrapped.Rollback(ctx), tx.rollbackErr)
}
func TestPgxPoolDBMethodsDelegateThroughThePool(t *testing.T) {
t.Parallel()
config, err := pgxpool.ParseConfig("postgres://xdrop:xdrop@127.0.0.1:1/xdrop?sslmode=disable")
require.NoError(t, err)
config.ConnConfig.ConnectTimeout = 20 * time.Millisecond
pool, err := pgxpool.NewWithConfig(context.Background(), config)
require.NoError(t, err)
t.Cleanup(pool.Close)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
wrapped := pgxPoolDB{pool: pool}
_, err = wrapped.Begin(ctx)
require.Error(t, err)
err = wrapped.Exec(ctx, "select 1")
require.Error(t, err)
_, err = wrapped.Query(ctx, "select 1")
require.Error(t, err)
row := wrapped.QueryRow(ctx, "select 1")
require.NotNil(t, row)
require.Error(t, row.Scan(new(int)))
}
+75 -1
View File
@@ -1,6 +1,12 @@
package testutil package testutil
import "testing" import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestParsePublishedPortParsesIPv4Binding(t *testing.T) { func TestParsePublishedPortParsesIPv4Binding(t *testing.T) {
t.Parallel() t.Parallel()
@@ -33,3 +39,71 @@ func TestParsePublishedPortRejectsUnexpectedFormat(t *testing.T) {
t.Fatal("parsePublishedPort succeeded for malformed output") t.Fatal("parsePublishedPort succeeded for malformed output")
} }
} }
func TestParsePublishedPortUsesTheFirstLineFromMultiLineOutput(t *testing.T) {
t.Parallel()
port, err := parsePublishedPort("127.0.0.1:49154\n[::]:32768\n")
if err != nil {
t.Fatalf("parsePublishedPort returned error: %v", err)
}
if port != "49154" {
t.Fatalf("parsePublishedPort returned %q, want %q", port, "49154")
}
}
func TestWaitForConditionRetriesUntilSuccess(t *testing.T) {
t.Parallel()
attempts := 0
err := WaitForCondition(context.Background(), 50*time.Millisecond, time.Millisecond, func() error {
attempts++
if attempts < 3 {
return errors.New("not yet")
}
return nil
})
if err != nil {
t.Fatalf("WaitForCondition returned error: %v", err)
}
if attempts != 3 {
t.Fatalf("WaitForCondition attempted %d times, want 3", attempts)
}
}
func TestWaitForConditionReturnsContextError(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := WaitForCondition(ctx, 50*time.Millisecond, time.Millisecond, func() error {
return errors.New("still waiting")
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("WaitForCondition returned %v, want context.Canceled", err)
}
}
func TestWaitForConditionIncludesLastErrorOnTimeout(t *testing.T) {
t.Parallel()
err := WaitForCondition(context.Background(), 5*time.Millisecond, time.Millisecond, func() error {
return errors.New("still failing")
})
if err == nil {
t.Fatal("WaitForCondition succeeded, want timeout error")
}
if !strings.Contains(err.Error(), "still failing") {
t.Fatalf("WaitForCondition returned %q, want to include the last error", err)
}
}
func TestUniqueContainerNameFallsBackToTheDefaultPrefix(t *testing.T) {
t.Parallel()
name := uniqueContainerName(" ")
if !strings.HasPrefix(name, "xdrop-test-") {
t.Fatalf("uniqueContainerName returned %q, want default prefix", name)
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage --maxWorkers=1",
"test:watch": "vitest", "test:watch": "vitest",
"typecheck": "tsc --noEmit -p tsconfig.app.json" "typecheck": "tsc --noEmit -p tsconfig.app.json"
}, },
@@ -159,4 +159,44 @@ describe('HistoryBoard', () => {
expect(screen.getByText('Could not update this transfer right now.')).toBeInTheDocument() expect(screen.getByText('Could not update this transfer right now.')).toBeInTheDocument()
expect(deleteTransferMock).toHaveBeenCalledWith('ready-1') expect(deleteTransferMock).toHaveBeenCalledWith('ready-1')
}) })
it('lets local-only transfers cancel a pending forget action and clears stale errors on retry', async () => {
transfersState.splice(
0,
transfersState.length,
createTransfer('local-only-2', 'Forgotten transfer', '2026-03-21T10:00:00.000Z', {
manageToken: '',
}),
)
deleteTransferMock.mockRejectedValueOnce(new Error('Delete failed'))
deleteTransferMock.mockResolvedValueOnce(undefined)
renderBoard()
fireEvent.click(screen.getByRole('button', { name: 'Forget local copy' }))
expect(screen.getByText(/Confirm forget to remove this local record/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Confirm forget' }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByText('Delete failed')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('button', { name: 'Confirm forget' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Forget local copy' }))
expect(screen.queryByText('Delete failed')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Confirm forget' }))
await act(async () => {
await Promise.resolve()
})
expect(deleteTransferMock).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('button', { name: 'Confirm forget' })).not.toBeInTheDocument()
})
}) })
@@ -65,7 +65,7 @@ export function HistoryBoard() {
clearActionError(transferId) clearActionError(transferId)
try { try {
await deleteTransfer(transferId) await deleteTransfer(transferId)
setPendingDeleteTransferId((current) => (current === transferId ? undefined : current)) setPendingDeleteTransferId(undefined)
} catch (error) { } catch (error) {
setActionError(transferId, error) setActionError(transferId, error)
} }
@@ -170,11 +170,7 @@ export function HistoryBoard() {
</Button> </Button>
<Button <Button
tone="ghost" tone="ghost"
onClick={() => onClick={() => setPendingDeleteTransferId(undefined)}
setPendingDeleteTransferId((current) =>
current === transfer.id ? undefined : current,
)
}
> >
Cancel Cancel
</Button> </Button>
@@ -212,6 +212,37 @@ describe('ShareCard', () => {
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
}) })
it('falls back to copying when the share sheet fails for non-abort reasons', async () => {
const shareMock = vi.fn(async () => {
throw new Error('share failed')
})
Object.assign(navigator, { share: shareMock })
render(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-4b', 'Fallback transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
await act(async () => {
await Promise.resolve()
})
fireEvent.click(screen.getByRole('button', { name: 'Share link' }))
await act(async () => {
await Promise.resolve()
})
expect(shareMock).toHaveBeenCalledTimes(1)
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
'https://example.com/t/active-4b#k=test',
)
})
it('shows a retry-focused fallback when clipboard access fails', async () => { it('shows a retry-focused fallback when clipboard access fails', async () => {
Object.assign(navigator, { Object.assign(navigator, {
clipboard: { clipboard: {
@@ -432,6 +463,43 @@ describe('ShareCard', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('guards copy and share handlers when a transfer has no share link', async () => {
render(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-10', 'Missing share link', '2026-03-21T10:00:00.000Z', {
shareUrl: '',
})}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
await act(async () => {
await Promise.resolve()
})
const copyButton = screen.getByRole('button', { name: 'Copy link' }) as HTMLButtonElement
const shareButton = screen.getByRole('button', { name: 'Share link' }) as HTMLButtonElement
expect(copyButton).toBeDisabled()
expect(shareButton).toBeDisabled()
copyButton.disabled = false
shareButton.disabled = false
fireEvent.click(copyButton)
fireEvent.click(shareButton)
await act(async () => {
await Promise.resolve()
})
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
expect(toDataUrlMock).not.toHaveBeenCalled()
expect(document.querySelector('.qr-placeholder')).not.toBeNull()
})
it('keeps the QR placeholder when QR generation fails', async () => { it('keeps the QR placeholder when QR generation fails', async () => {
toDataUrlMock.mockRejectedValueOnce(new Error('QR failed')) toDataUrlMock.mockRejectedValueOnce(new Error('QR failed'))
+57 -56
View File
@@ -78,64 +78,61 @@ export function ShareCard({ transfer, onExtendTransfer }: Props) {
}, [qrShareUrl]) }, [qrShareUrl])
/** copyLink writes the full share URL, including the `#k=` fragment, to the clipboard. */ /** copyLink writes the full share URL, including the `#k=` fragment, to the clipboard. */
const copyLink = async () => { const copyLink = qrShareUrl
if (!transfer?.shareUrl || !canShareLink) { ? async () => {
return try {
} setActionError(undefined)
try { await navigator.clipboard.writeText(qrShareUrl)
setActionError(undefined) setCopyState('copied')
await navigator.clipboard.writeText(transfer.shareUrl) window.setTimeout(() => setCopyState('idle'), 1800)
setCopyState('copied') } catch {
window.setTimeout(() => setCopyState('idle'), 1800) setCopyState('idle')
} catch { setActionError('Copy failed. Use your browser share tools or try again on this device.')
setCopyState('idle') }
setActionError('Copy failed. Use your browser share tools or try again on this device.') }
} : undefined
}
/** shareLink uses the platform share sheet when available and falls back to copying. */ /** shareLink uses the platform share sheet when available and falls back to copying. */
const shareLink = async () => { const shareLink = qrShareUrl
if (!transfer?.shareUrl || !canShareLink) { ? async () => {
return if (navigator.share) {
} try {
if (navigator.share) { setActionError(undefined)
try { await navigator.share({
setActionError(undefined) title: transfer?.displayName,
await navigator.share({ url: qrShareUrl,
title: transfer.displayName, text: 'Encrypted files via Xdrop',
url: transfer.shareUrl, })
text: 'Encrypted files via Xdrop', return
}) } catch (error) {
return if (isAbortLike(error)) {
} catch (error) { return
if (isAbortLike(error)) { }
return await copyLink?.()
return
}
} }
await copyLink()
return await copyLink?.()
} }
} : undefined
await copyLink() const extendExpiry =
} canExtendTransfer && transfer && onExtendTransfer
? async () => {
const extendExpiry = async () => { try {
if (!transfer || !canExtendTransfer || !onExtendTransfer) { setActionError(undefined)
return setIsExtending(true)
} await onExtendTransfer(transfer.id, MAX_EXPIRY_SECONDS)
} catch (error) {
try { setActionError(
setActionError(undefined) error instanceof Error ? error.message : 'Could not update this transfer right now.',
setIsExtending(true) )
await onExtendTransfer(transfer.id, MAX_EXPIRY_SECONDS) } finally {
} catch (error) { setIsExtending(false)
setActionError( }
error instanceof Error ? error.message : 'Could not update this transfer right now.', }
) : undefined
} finally {
setIsExtending(false)
}
}
if (!transfer) { if (!transfer) {
return ( return (
@@ -229,13 +226,17 @@ export function ShareCard({ transfer, onExtendTransfer }: Props) {
</dl> </dl>
<div className="button-row"> <div className="button-row">
<Button onClick={() => void copyLink()} disabled={!canShareLink}> <Button onClick={copyLink ? () => void copyLink() : undefined} disabled={!copyLink}>
{copyState === 'copied' ? 'Copied' : 'Copy link'} {copyState === 'copied' ? 'Copied' : 'Copy link'}
</Button> </Button>
<Button onClick={() => void shareLink()} tone="ghost" disabled={!canShareLink}> <Button
onClick={shareLink ? () => void shareLink() : undefined}
tone="ghost"
disabled={!shareLink}
>
Share link Share link
</Button> </Button>
{canExtendTransfer ? ( {extendExpiry ? (
<Button tone="ghost" onClick={() => void extendExpiry()} disabled={isExtending}> <Button tone="ghost" onClick={() => void extendExpiry()} disabled={isExtending}>
{isExtending {isExtending
? 'Updating expiry…' ? 'Updating expiry…'
@@ -554,4 +554,41 @@ describe('TransferProvider actions', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
}) })
it('aborts active upload runtimes when deleting a transfer mid-flight', async () => {
transfersStore.set('t1', makeTransferRecord('uploading'))
sourcesStore.set('t1:file-1', makeSourceRecord('t1'))
let aborts = 0
vi.stubGlobal(
'fetch',
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise((_, reject) => {
const signal = init?.signal as AbortSignal | undefined
signal?.addEventListener('abort', () => {
aborts += 1
reject(new DOMException('aborted', 'AbortError'))
})
}),
),
)
renderProvider()
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled()
})
await waitFor(() => expect(latestContext).not.toBeNull())
await act(async () => {
await latestContext?.deleteTransfer('t1')
})
expect(aborts).toBeGreaterThan(0)
expect(deleteRemoteTransferMock).toHaveBeenCalledWith('t1', 'manage-token')
expect(deletePersistedTransferSourcesMock).toHaveBeenCalledWith('t1')
expect(deleteTransferRecordMock).toHaveBeenCalledWith('t1')
expect(transfersStore.has('t1')).toBe(false)
})
}) })
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { import {
DEFAULT_EXPIRY_SECONDS, DEFAULT_EXPIRY_SECONDS,
EXPIRY_OPTIONS, EXPIRY_OPTIONS,
@@ -355,6 +355,69 @@ describe('UploadStudio', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('avoids updating state when draft persistence resolves after unmount', async () => {
const persistedFile = new File(['hello'], 'draft.txt', { lastModified: 12, type: 'text/plain' })
let resolvePersist: ((sources: typeof draftState.sources) => void) | undefined
persistDraftMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePersist = resolve
}),
)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { container, unmount } = renderStudio()
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [persistedFile] },
})
unmount()
await act(async () => {
resolvePersist?.([
{
draftKey: 'draft-1',
file: persistedFile,
relativePath: 'draft.txt',
},
])
await Promise.resolve()
})
expect(errorSpy).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
it('avoids surfacing persistence errors after unmount', async () => {
let rejectPersist: ((reason?: unknown) => void) | undefined
persistDraftMock.mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectPersist = reject
}),
)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { container, unmount } = renderStudio()
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: {
files: [new File(['hello'], 'draft.txt', { lastModified: 12, type: 'text/plain' })],
},
})
unmount()
await act(async () => {
rejectPersist?.(new Error('persist failed'))
await Promise.resolve()
})
expect(errorSpy).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
it('prefers webkitRelativePath over the bare filename when a folder is chosen', async () => { it('prefers webkitRelativePath over the bare filename when a folder is chosen', async () => {
const { container } = renderStudio() const { container } = renderStudio()
const nestedFile = new File(['folder'], 'nested.txt', { lastModified: 14, type: 'text/plain' }) const nestedFile = new File(['folder'], 'nested.txt', { lastModified: 14, type: 'text/plain' })
@@ -153,12 +153,6 @@ export function UploadStudio() {
/** launchTransfer hands the staged sources to the transfer runtime and navigates to share view. */ /** launchTransfer hands the staged sources to the transfer runtime and navigates to share view. */
const launchTransfer = async () => { const launchTransfer = async () => {
if (selectionLimitError) {
setSelectionError(selectionLimitError)
setLaunchError(undefined)
return
}
try { try {
setIsLaunching(true) setIsLaunching(true)
setLaunchError(undefined) setLaunchError(undefined)
+55 -1
View File
@@ -1,9 +1,18 @@
import { describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { sanitizePath } from '@/lib/files/paths' import { sanitizePath } from '@/lib/files/paths'
import { buildBinaryZip, buildTextZip, buildZip, writeZipStream } from './zip' import { buildBinaryZip, buildTextZip, buildZip, writeZipStream } from './zip'
describe('zip builder', () => { describe('zip builder', () => {
const OriginalResponse = globalThis.Response
afterEach(() => {
Object.defineProperty(globalThis, 'Response', {
configurable: true,
value: OriginalResponse,
})
})
it('preserves sanitized folder structure', async () => { it('preserves sanitized folder structure', async () => {
const zipBlob = await buildZip([ const zipBlob = await buildZip([
{ path: '../folder/photo.png', blob: new Blob(['a']) }, { path: '../folder/photo.png', blob: new Blob(['a']) },
@@ -101,4 +110,49 @@ describe('zip builder', () => {
).resolves.toBeUndefined() ).resolves.toBeUndefined()
expect(chunks.length).toBeGreaterThan(0) expect(chunks.length).toBeGreaterThan(0)
}) })
it('uses arrayBuffer-backed blobs when stream() is unavailable', async () => {
const blobLike = {
arrayBuffer: async () => new TextEncoder().encode('hello').buffer,
size: 5,
type: 'text/plain',
} as unknown as Blob
const zipBlob = await buildZip([{ path: 'fallback/data.txt', blob: blobLike }])
expect(zipBlob.type).toBe('application/zip')
expect(zipBlob.size).toBeGreaterThan(0)
})
it('falls back to Response bodies when blob helpers are unavailable', async () => {
const blobLike = {
size: 5,
type: 'text/plain',
} as unknown as Blob
const zipBlob = await buildZip([{ path: 'fallback/data.txt', blob: blobLike }])
expect(zipBlob.type).toBe('application/zip')
expect(zipBlob.size).toBeGreaterThan(0)
})
it('throws when no blob streaming strategy is available', async () => {
class ResponseWithoutBody {
readonly body = null
}
Object.defineProperty(globalThis, 'Response', {
configurable: true,
value: ResponseWithoutBody,
})
const blobLike = {
size: 5,
type: 'text/plain',
} as unknown as Blob
await expect(buildZip([{ path: 'fallback/data.txt', blob: blobLike }])).rejects.toThrow(
'Blob stream is unavailable.',
)
})
}) })
@@ -137,6 +137,48 @@ describe('persistentSources', () => {
).resolves.toBeNull() ).resolves.toBeNull()
}) })
it('returns null when navigator is unavailable or the OPFS root cannot be opened', async () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: undefined,
})
await expect(
persistSourceToOpfs({
file: new File(['hello'], 'draft.txt'),
fileId: 'file-1',
relativePath: 'draft.txt',
transferId: 'transfer-1',
}),
).resolves.toBeNull()
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: originalNavigator,
})
const harness = createOpfsHarness()
harness.storage.getDirectory.mockResolvedValueOnce({
getDirectoryHandle: vi.fn(async () => {
throw new Error('denied')
}),
getFileHandle: vi.fn(),
removeEntry: vi.fn(async () => {}),
})
stubStorage(harness.storage)
await expect(
persistSourceToOpfs({
file: new File(['hello'], 'draft.txt'),
fileId: 'file-1',
relativePath: 'draft.txt',
transferId: 'transfer-1',
}),
).resolves.toBeNull()
})
it('restores indexeddb-backed files and handles missing files', async () => { it('restores indexeddb-backed files and handles missing files', async () => {
const file = new File(['payload'], 'example.txt', { type: 'text/plain', lastModified: 5 }) const file = new File(['payload'], 'example.txt', { type: 'text/plain', lastModified: 5 })
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { formatLocalDateTime } from './formatDateTime'
const originalNavigator = globalThis.navigator
describe('formatLocalDateTime', () => {
afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: originalNavigator,
})
vi.restoreAllMocks()
})
it('returns an empty string for invalid date values', () => {
expect(formatLocalDateTime('not-a-date')).toBe('')
})
it('prefers navigator.language when navigator.languages is empty', () => {
let observedLocales: Intl.LocalesArgument | undefined
let observedOptions: Intl.DateTimeFormatOptions | undefined
const DateTimeFormatMock = vi
.spyOn(Intl, 'DateTimeFormat')
.mockImplementation(function (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) {
observedLocales = locales
observedOptions = options
return {
format: () => 'formatted',
} as Intl.DateTimeFormat
})
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
language: 'fr-FR',
languages: [],
},
})
expect(formatLocalDateTime('2026-03-20T10:00:00.000Z')).toBe('formatted')
expect(DateTimeFormatMock).toHaveBeenCalledTimes(1)
expect(observedLocales).toEqual(['fr-FR'])
expect(observedOptions).toMatchObject({
dateStyle: 'medium',
timeStyle: 'short',
})
})
it('falls back to an undefined locale list when navigator is unavailable', () => {
let observedLocales: Intl.LocalesArgument | undefined
let observedOptions: Intl.DateTimeFormatOptions | undefined
const DateTimeFormatMock = vi
.spyOn(Intl, 'DateTimeFormat')
.mockImplementation(function (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) {
observedLocales = locales
observedOptions = options
return {
format: () => 'formatted',
} as Intl.DateTimeFormat
})
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: undefined,
})
expect(formatLocalDateTime('2026-03-20T10:00:00.000Z')).toBe('formatted')
expect(DateTimeFormatMock).toHaveBeenCalledTimes(1)
expect(observedLocales).toBeUndefined()
expect(observedOptions).toMatchObject({
dateStyle: 'medium',
timeStyle: 'short',
})
})
})