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
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user