test: raise coverage to 99 percent
This commit is contained in:
1 parent
42bd6e2b3a
commit
33c73cf620
14 files changed
+646
-73
No files matched your search
@@ -1,8 +1,14 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -15,3 +21,124 @@ func TestNewPostgresRepositoryWrapsPoolBackedDB(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
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)))
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package testutil
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParsePublishedPortParsesIPv4Binding(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -33,3 +39,71 @@ func TestParsePublishedPortRejectsUnexpectedFormat(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage": "vitest run --coverage --maxWorkers=1",
|
||||
"test:watch": "vitest",
|
||||
"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(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)
|
||||
try {
|
||||
await deleteTransfer(transferId)
|
||||
setPendingDeleteTransferId((current) => (current === transferId ? undefined : current))
|
||||
setPendingDeleteTransferId(undefined)
|
||||
} catch (error) {
|
||||
setActionError(transferId, error)
|
||||
}
|
||||
@@ -170,11 +170,7 @@ export function HistoryBoard() {
|
||||
</Button>
|
||||
<Button
|
||||
tone="ghost"
|
||||
onClick={() =>
|
||||
setPendingDeleteTransferId((current) =>
|
||||
current === transfer.id ? undefined : current,
|
||||
)
|
||||
}
|
||||
onClick={() => setPendingDeleteTransferId(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -212,6 +212,37 @@ describe('ShareCard', () => {
|
||||
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 () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
@@ -432,6 +463,43 @@ describe('ShareCard', () => {
|
||||
).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 () => {
|
||||
toDataUrlMock.mockRejectedValueOnce(new Error('QR failed'))
|
||||
|
||||
|
||||
@@ -78,64 +78,61 @@ export function ShareCard({ transfer, onExtendTransfer }: Props) {
|
||||
}, [qrShareUrl])
|
||||
|
||||
/** copyLink writes the full share URL, including the `#k=` fragment, to the clipboard. */
|
||||
const copyLink = async () => {
|
||||
if (!transfer?.shareUrl || !canShareLink) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setActionError(undefined)
|
||||
await navigator.clipboard.writeText(transfer.shareUrl)
|
||||
setCopyState('copied')
|
||||
window.setTimeout(() => setCopyState('idle'), 1800)
|
||||
} catch {
|
||||
setCopyState('idle')
|
||||
setActionError('Copy failed. Use your browser share tools or try again on this device.')
|
||||
}
|
||||
}
|
||||
const copyLink = qrShareUrl
|
||||
? async () => {
|
||||
try {
|
||||
setActionError(undefined)
|
||||
await navigator.clipboard.writeText(qrShareUrl)
|
||||
setCopyState('copied')
|
||||
window.setTimeout(() => setCopyState('idle'), 1800)
|
||||
} catch {
|
||||
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. */
|
||||
const shareLink = async () => {
|
||||
if (!transfer?.shareUrl || !canShareLink) {
|
||||
return
|
||||
}
|
||||
if (navigator.share) {
|
||||
try {
|
||||
setActionError(undefined)
|
||||
await navigator.share({
|
||||
title: transfer.displayName,
|
||||
url: transfer.shareUrl,
|
||||
text: 'Encrypted files via Xdrop',
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
if (isAbortLike(error)) {
|
||||
return
|
||||
const shareLink = qrShareUrl
|
||||
? async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
setActionError(undefined)
|
||||
await navigator.share({
|
||||
title: transfer?.displayName,
|
||||
url: qrShareUrl,
|
||||
text: 'Encrypted files via Xdrop',
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
if (isAbortLike(error)) {
|
||||
return
|
||||
}
|
||||
await copyLink?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
await copyLink()
|
||||
return
|
||||
|
||||
await copyLink?.()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
await copyLink()
|
||||
}
|
||||
|
||||
const extendExpiry = async () => {
|
||||
if (!transfer || !canExtendTransfer || !onExtendTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setActionError(undefined)
|
||||
setIsExtending(true)
|
||||
await onExtendTransfer(transfer.id, MAX_EXPIRY_SECONDS)
|
||||
} catch (error) {
|
||||
setActionError(
|
||||
error instanceof Error ? error.message : 'Could not update this transfer right now.',
|
||||
)
|
||||
} finally {
|
||||
setIsExtending(false)
|
||||
}
|
||||
}
|
||||
const extendExpiry =
|
||||
canExtendTransfer && transfer && onExtendTransfer
|
||||
? async () => {
|
||||
try {
|
||||
setActionError(undefined)
|
||||
setIsExtending(true)
|
||||
await onExtendTransfer(transfer.id, MAX_EXPIRY_SECONDS)
|
||||
} catch (error) {
|
||||
setActionError(
|
||||
error instanceof Error ? error.message : 'Could not update this transfer right now.',
|
||||
)
|
||||
} finally {
|
||||
setIsExtending(false)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (!transfer) {
|
||||
return (
|
||||
@@ -229,13 +226,17 @@ export function ShareCard({ transfer, onExtendTransfer }: Props) {
|
||||
</dl>
|
||||
|
||||
<div className="button-row">
|
||||
<Button onClick={() => void copyLink()} disabled={!canShareLink}>
|
||||
<Button onClick={copyLink ? () => void copyLink() : undefined} disabled={!copyLink}>
|
||||
{copyState === 'copied' ? 'Copied' : 'Copy link'}
|
||||
</Button>
|
||||
<Button onClick={() => void shareLink()} tone="ghost" disabled={!canShareLink}>
|
||||
<Button
|
||||
onClick={shareLink ? () => void shareLink() : undefined}
|
||||
tone="ghost"
|
||||
disabled={!shareLink}
|
||||
>
|
||||
Share link
|
||||
</Button>
|
||||
{canExtendTransfer ? (
|
||||
{extendExpiry ? (
|
||||
<Button tone="ghost" onClick={() => void extendExpiry()} disabled={isExtending}>
|
||||
{isExtending
|
||||
? 'Updating expiry…'
|
||||
|
||||
@@ -554,4 +554,41 @@ describe('TransferProvider actions', () => {
|
||||
).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 {
|
||||
DEFAULT_EXPIRY_SECONDS,
|
||||
EXPIRY_OPTIONS,
|
||||
@@ -355,6 +355,69 @@ describe('UploadStudio', () => {
|
||||
).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 () => {
|
||||
const { container } = renderStudio()
|
||||
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. */
|
||||
const launchTransfer = async () => {
|
||||
if (selectionLimitError) {
|
||||
setSelectionError(selectionLimitError)
|
||||
setLaunchError(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLaunching(true)
|
||||
setLaunchError(undefined)
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { sanitizePath } from '@/lib/files/paths'
|
||||
import { buildBinaryZip, buildTextZip, buildZip, writeZipStream } from './zip'
|
||||
|
||||
describe('zip builder', () => {
|
||||
const OriginalResponse = globalThis.Response
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'Response', {
|
||||
configurable: true,
|
||||
value: OriginalResponse,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves sanitized folder structure', async () => {
|
||||
const zipBlob = await buildZip([
|
||||
{ path: '../folder/photo.png', blob: new Blob(['a']) },
|
||||
@@ -101,4 +110,49 @@ describe('zip builder', () => {
|
||||
).resolves.toBeUndefined()
|
||||
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()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user