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) {
- - - {canExtendTransfer ? ( + {extendExpiry ? (