Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 35 additions & 15 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ on:
branches: [ main, develop ]
paths:
- 'packages/web/**'
- 'packages/backend/**'
- 'packages/shared-schema/**'
- 'packages/db/**'
- '.github/workflows/e2e-tests.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'packages/web/**'
- 'packages/backend/**'
- 'packages/shared-schema/**'
- 'packages/db/**'
- '.github/workflows/e2e-tests.yml'
Expand All @@ -20,30 +21,44 @@ jobs:
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.57.0-noble

services:
postgres:
image: ghcr.io/marcodejongh/boardsesh-postgres-postgis:latest
image: ghcr.io/marcodejongh/boardsesh-dev-db:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: boardsesh_test
ports:
- 5432:5432
POSTGRES_PASSWORD: password
POSTGRES_DB: main
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--shm-size 256mb

neon-proxy:
image: ghcr.io/timowilhelm/local-neon-http-proxy:main
env:
PG_CONNECTION_STRING: postgres://postgres:password@postgres:5432/main
POSTGRES_DB: main

steps:
- uses: actions/checkout@v4

- name: Point db.localtest.me to neon-proxy service
run: |
# Resolve the neon-proxy service IP and add a hosts entry so the
# Neon driver's fetchEndpoint (http://db.localtest.me:4444/sql) reaches
# the proxy container instead of localhost.
PROXY_IP=$(getent hosts neon-proxy | awk '{ print $1 }')
echo "$PROXY_IP db.localtest.me" >> /etc/hosts

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci --legacy-peer-deps
Expand All @@ -54,34 +69,39 @@ jobs:
- name: Build db package
run: npm run build --workspace=@boardsesh/db

- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
working-directory: packages/web
- name: Build backend
run: npm run build --workspace=boardsesh-backend

- name: Build web application
run: npm run build --workspace=@boardsesh/web
env:
# Provide minimal env vars needed for build
NEXTAUTH_SECRET: test-secret-for-ci
NEXTAUTH_URL: http://localhost:3000
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/boardsesh_test
DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main
NEXT_PUBLIC_WS_URL: ws://localhost:8080/graphql

- name: Run database migrations
run: npx drizzle-kit migrate
working-directory: packages/web
working-directory: packages/db
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/boardsesh_test
DATABASE_URL: postgresql://postgres:password@postgres:5432/main

- name: Start server and run E2E tests
- name: Start backend and web server, then run E2E tests
run: |
npm run start --workspace=boardsesh-backend &
npx wait-on http-get://localhost:8080/health --timeout 30000
npm run start --workspace=@boardsesh/web &
npx wait-on http://localhost:3000 --timeout 60000
npm run test:e2e --workspace=@boardsesh/web
env:
PLAYWRIGHT_TEST_BASE_URL: http://localhost:3000
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/boardsesh_test
DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main
NEXTAUTH_SECRET: test-secret-for-ci
NEXTAUTH_URL: http://localhost:3000
NEXT_PUBLIC_WS_URL: ws://localhost:8080/graphql
TEST_USER_EMAIL: test@boardsesh.com
TEST_USER_PASSWORD: test

- name: Upload Playwright Report
uses: actions/upload-artifact@v4
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/moonboard-ocr-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ on:
- 'packages/moonboard-ocr/**'
- '.github/workflows/moonboard-ocr-tests.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'packages/moonboard-ocr/**'
- '.github/workflows/moonboard-ocr-tests.yml'
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ on:
- 'packages/db/**'
- '.github/workflows/test.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'packages/web/**'
- 'packages/shared-schema/**'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import React from 'react';

// ---------------------------------------------------------------------------
// Mocks — must be defined before importing the SUT
// ---------------------------------------------------------------------------

const mockWsAuth = {
token: null as string | null,
isAuthenticated: false,
isLoading: false,
error: null as string | null,
};

vi.mock('@/app/hooks/use-ws-auth-token', () => ({
useWsAuthToken: () => mockWsAuth,
}));

const mockRequest = vi.fn();

vi.mock('@/app/lib/graphql/client', () => ({
createGraphQLHttpClient: () => ({ request: mockRequest }),
}));

// Provide a minimal QueryClient wrapper since useWsAuthToken uses TanStack Query
// (but we've mocked it, so this is just for potential other hooks)
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query');
return {
...actual,
useQuery: vi.fn().mockReturnValue({ data: undefined, isLoading: false, error: null }),
};
});

// Now import the SUT — after all vi.mock calls
import ActivityFeed from '../activity-feed';
import type { ActivityFeedItem } from '@boardsesh/shared-schema';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function createMockItem(overrides?: Partial<ActivityFeedItem>): ActivityFeedItem {
return {
id: `item-${Math.random().toString(36).slice(2)}`,
type: 'ascent',
entityType: 'tick',
entityId: 'entity-1',
actorDisplayName: 'Test Climber',
climbName: 'Test Climb V3',
boardType: 'kilter',
createdAt: new Date().toISOString(),
...overrides,
};
}

function createFeedResponse(items: ActivityFeedItem[], hasMore = false) {
return {
items,
cursor: hasMore ? 'next-cursor' : null,
hasMore,
};
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('ActivityFeed', () => {
beforeEach(() => {
vi.clearAllMocks();
mockWsAuth.token = null;
mockWsAuth.isAuthenticated = false;
mockWsAuth.isLoading = false;
mockWsAuth.error = null;
});

describe('authSessionLoading behavior', () => {
it('shows initialItems when authSessionLoading is true', () => {
const items = [createMockItem({ climbName: 'Boulder Problem Alpha' })];

render(
<ActivityFeed
isAuthenticated={false}
authSessionLoading={true}
initialItems={items}
/>,
);

expect(screen.getByText('Boulder Problem Alpha')).toBeInTheDocument();
expect(mockRequest).not.toHaveBeenCalled();
expect(screen.queryByText('Follow climbers to see their activity here')).not.toBeInTheDocument();
});

it('shows loading spinner when authSessionLoading is true and no initialItems', () => {
render(
<ActivityFeed
isAuthenticated={false}
authSessionLoading={true}
/>,
);

expect(screen.getByTestId('activity-feed-loading')).toBeInTheDocument();
expect(screen.queryByText('Follow climbers to see their activity here')).not.toBeInTheDocument();
expect(mockRequest).not.toHaveBeenCalled();
});
});

describe('feed type selection', () => {
it('fetches trending feed when unauthenticated', async () => {
const trendingItems = [createMockItem({ climbName: 'Trending Route' })];
mockRequest.mockResolvedValueOnce({
trendingFeed: createFeedResponse(trendingItems),
});

render(
<ActivityFeed
isAuthenticated={false}
authSessionLoading={false}
/>,
);

await waitFor(() => {
expect(mockRequest).toHaveBeenCalledTimes(1);
});

// Should have called with the trending feed query (second positional arg: GET_TRENDING_FEED)
const callArgs = mockRequest.mock.calls[0];
expect(callArgs[0]).toContain('trendingFeed');
});

it('fetches authenticated feed when authenticated with token', async () => {
mockWsAuth.token = 'jwt-token';
mockWsAuth.isAuthenticated = true;

const authItems = [createMockItem({ climbName: 'My Feed Route' })];
mockRequest.mockResolvedValueOnce({
activityFeed: createFeedResponse(authItems),
});

render(
<ActivityFeed
isAuthenticated={true}
authSessionLoading={false}
/>,
);

await waitFor(() => {
expect(mockRequest).toHaveBeenCalledTimes(1);
});

const callArgs = mockRequest.mock.calls[0];
expect(callArgs[0]).toContain('activityFeed');
});
});

describe('stale fetch protection', () => {
it('discards stale fetch results when a newer fetch supersedes', async () => {
// First render: unauthenticated, slow response
let resolveFirst: (value: unknown) => void;
const firstPromise = new Promise((resolve) => { resolveFirst = resolve; });

const staleItems = [createMockItem({ climbName: 'Stale Item' })];
const freshItems = [createMockItem({ climbName: 'Fresh Item' })];

mockRequest.mockReturnValueOnce(firstPromise);

const { rerender } = render(
<ActivityFeed
isAuthenticated={false}
authSessionLoading={false}
/>,
);

// Wait for the first fetch to be initiated
await waitFor(() => {
expect(mockRequest).toHaveBeenCalledTimes(1);
});

// Second render: now authenticated with token (session resolved)
mockWsAuth.token = 'jwt-token';
mockWsAuth.isAuthenticated = true;

mockRequest.mockResolvedValueOnce({
activityFeed: createFeedResponse(freshItems),
});

rerender(
<ActivityFeed
isAuthenticated={true}
authSessionLoading={false}
/>,
);

// Wait for fresh fetch to complete
await waitFor(() => {
expect(screen.getByText('Fresh Item')).toBeInTheDocument();
});

// Now resolve the stale first fetch — it should be discarded
await act(async () => {
resolveFirst!({
trendingFeed: createFeedResponse(staleItems),
});
});

// Fresh items should still be shown, stale items should not appear
expect(screen.getByText('Fresh Item')).toBeInTheDocument();
expect(screen.queryByText('Stale Item')).not.toBeInTheDocument();
});
});

describe('empty state protection', () => {
it('never shows empty state while waiting for auth token', async () => {
mockWsAuth.token = null;
mockWsAuth.isLoading = true;

const items = [createMockItem({ climbName: 'Existing Item' })];

render(
<ActivityFeed
isAuthenticated={true}
authSessionLoading={false}
initialItems={items}
/>,
);

// Items should remain visible
expect(screen.getByText('Existing Item')).toBeInTheDocument();
// Empty state should not appear
expect(screen.queryByText('Follow climbers to see their activity here')).not.toBeInTheDocument();
// No fetch should have been made yet (waiting for token)
expect(mockRequest).not.toHaveBeenCalled();
});

it('shows empty state only after authenticated fetch returns empty', async () => {
mockWsAuth.token = 'jwt-token';
mockWsAuth.isAuthenticated = true;

mockRequest.mockResolvedValueOnce({
activityFeed: createFeedResponse([]),
});

render(
<ActivityFeed
isAuthenticated={true}
authSessionLoading={false}
/>,
);

await waitFor(() => {
expect(screen.getByTestId('activity-feed-empty-state')).toBeInTheDocument();
});

expect(screen.getByText('Follow climbers to see their activity here')).toBeInTheDocument();
});
});
});
Loading
Loading