diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d02dafe..32218ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -149,7 +149,8 @@ jobs:
fi
- 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
run: go tool cover -func=coverage.out
diff --git a/apps/api/internal/repo/postgres_wrapper_test.go b/apps/api/internal/repo/postgres_wrapper_test.go
index de0fbb3..c598284 100644
--- a/apps/api/internal/repo/postgres_wrapper_test.go
+++ b/apps/api/internal/repo/postgres_wrapper_test.go
@@ -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)))
+}
diff --git a/apps/api/internal/testutil/docker_test.go b/apps/api/internal/testutil/docker_test.go
index 78ec94d..cad47ca 100644
--- a/apps/api/internal/testutil/docker_test.go
+++ b/apps/api/internal/testutil/docker_test.go
@@ -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)
+ }
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 30cfda6..fb48bbb 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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"
},
diff --git a/apps/web/src/features/history/HistoryBoard.test.tsx b/apps/web/src/features/history/HistoryBoard.test.tsx
index 5cf3755..ef92c46 100644
--- a/apps/web/src/features/history/HistoryBoard.test.tsx
+++ b/apps/web/src/features/history/HistoryBoard.test.tsx
@@ -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()
+ })
})
diff --git a/apps/web/src/features/history/HistoryBoard.tsx b/apps/web/src/features/history/HistoryBoard.tsx
index c5ef1bb..629c852 100644
--- a/apps/web/src/features/history/HistoryBoard.tsx
+++ b/apps/web/src/features/history/HistoryBoard.tsx
@@ -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() {
diff --git a/apps/web/src/features/share/ShareCard.test.tsx b/apps/web/src/features/share/ShareCard.test.tsx
index 1f4aaf6..206bca1 100644
--- a/apps/web/src/features/share/ShareCard.test.tsx
+++ b/apps/web/src/features/share/ShareCard.test.tsx
@@ -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(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ 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'))
diff --git a/apps/web/src/features/share/ShareCard.tsx b/apps/web/src/features/share/ShareCard.tsx
index 98ffe77..27c45e0 100644
--- a/apps/web/src/features/share/ShareCard.tsx
+++ b/apps/web/src/features/share/ShareCard.tsx
@@ -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) {
-