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
60 changes: 60 additions & 0 deletions frontend/src/__tests__/activity-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { normalizeActivityFeed } from '../api/activity';

describe('normalizeActivityFeed', () => {
it('normalizes nested API payloads and sorts newest first', () => {
const events = normalizeActivityFeed({
events: [
{
id: 'older',
event_type: 'bounty_posted',
actor: 'SolFoundry',
message: 'Bounty #100 is live',
created_at: '2026-05-29T11:00:00.000Z',
},
{
id: 'newer',
type: 'payout_released',
username: 'builder',
detail: 'Bounty #99 payout released',
timestamp: '2026-05-29T12:00:00.000Z',
},
],
});

expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
id: 'newer',
type: 'payout',
username: 'builder',
});
expect(events[1]).toMatchObject({
id: 'older',
type: 'posted',
username: 'SolFoundry',
});
});

it('handles array payloads with alternate keys', () => {
const events = normalizeActivityFeed([
{
transaction_signature: 'abc123',
kind: 'pull_request_submitted',
contributor_username: 'octo',
description: 'Opened PR #10',
block_time: '2026-05-29T12:30:00.000Z',
},
]);

expect(events).toEqual([
{
id: 'abc123',
type: 'submitted',
username: 'octo',
avatar_url: null,
detail: 'Opened PR #10',
timestamp: '2026-05-29T12:30:00.000Z',
},
]);
});
});
113 changes: 113 additions & 0 deletions frontend/src/__tests__/home-activity-feed.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { ActivityFeed } from '../components/home/ActivityFeed';

const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

function okJson(data: unknown): Response {
return {
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve(data),
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone() { return this; },
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve(JSON.stringify(data)),
} as Response;
}

function failJson(status: number): Response {
return {
ok: false,
status,
statusText: 'Error',
json: () => Promise.resolve({ message: 'error' }),
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone() { return this; },
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve('{"message":"error"}'),
} as Response;
}

function renderWithQuery(ui: React.ReactElement) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});

return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}

describe('ActivityFeed', () => {
beforeEach(() => {
mockFetch.mockReset();
});

it('renders API-backed events', async () => {
mockFetch.mockResolvedValue(
okJson({
events: [
{
id: 'evt-1',
type: 'payout_released',
username: 'octocat',
detail: 'Bounty #42',
timestamp: '2026-05-29T12:00:00.000Z',
},
],
}),
);

renderWithQuery(<ActivityFeed />);

await waitFor(() => {
expect(screen.getByText('octocat')).toBeInTheDocument();
});
expect(screen.getByText(/received payout for/i)).toBeInTheDocument();
expect(screen.getByText('Bounty #42')).toBeInTheDocument();
});

it('shows empty state when API returns no events', async () => {
mockFetch.mockResolvedValue(okJson({ events: [] }));

renderWithQuery(<ActivityFeed />);

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

it('shows graceful fallback when API is unavailable', async () => {
mockFetch.mockResolvedValue(failJson(503));

renderWithQuery(<ActivityFeed />);

await waitFor(() => {
expect(screen.getByTestId('activity-feed-error')).toBeInTheDocument();
});
expect(screen.getByText('Activity feed unavailable right now.')).toBeInTheDocument();
});
});
130 changes: 130 additions & 0 deletions frontend/src/api/activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { apiClient } from '../services/apiClient';
import type { ActivityEvent, ActivityEventType } from '../types/activity';

type UnknownRecord = Record<string, unknown>;

function asRecord(value: unknown): UnknownRecord | null {
return typeof value === 'object' && value !== null ? (value as UnknownRecord) : null;
}

function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}

function normalizeType(value: unknown): ActivityEventType {
const raw = (asString(value) ?? '').toLowerCase();

if (raw.includes('payout') || raw.includes('released') || raw.includes('paid')) {
return 'payout';
}
if (raw.includes('review') || raw.includes('score')) {
return 'review';
}
if (raw.includes('submit') || raw.includes('pull_request') || raw.includes('pr_')) {
return 'submitted';
}
if (raw.includes('post') || raw.includes('created') || raw.includes('publish')) {
return 'posted';
}
if (raw.includes('complete') || raw.includes('approve') || raw.includes('win')) {
return 'completed';
}

return 'posted';
}

function normalizeTimestamp(record: UnknownRecord): string {
return (
asString(record.timestamp) ??
asString(record.created_at) ??
asString(record.createdAt) ??
asString(record.block_time) ??
asString(record.blockTime) ??
new Date().toISOString()
);
}

function normalizeUsername(record: UnknownRecord): string {
return (
asString(record.username) ??
asString(record.user_name) ??
asString(record.actor) ??
asString(record.actor_username) ??
asString(record.creator_username) ??
asString(record.contributor_username) ??
asString(record.org_name) ??
'Unknown'
);
}

function normalizeDetail(record: UnknownRecord): string {
return (
asString(record.detail) ??
asString(record.message) ??
asString(record.description) ??
asString(record.title) ??
asString(record.summary) ??
'Recent platform activity'
);
}

function normalizeAvatar(record: UnknownRecord): string | null {
return (
asString(record.avatar_url) ??
asString(record.avatarUrl) ??
asString(record.user_avatar) ??
asString(record.actor_avatar_url)
);
}

function normalizeId(record: UnknownRecord, index: number): string {
return (
asString(record.id) ??
asString(record.event_id) ??
asString(record.tx_signature) ??
asString(record.transaction_signature) ??
`${normalizeType(record.type ?? record.event_type)}-${normalizeTimestamp(record)}-${index}`
);
}

function normalizeEvent(value: unknown, index: number): ActivityEvent | null {
const record = asRecord(value);
if (!record) return null;

return {
id: normalizeId(record, index),
type: normalizeType(record.type ?? record.event_type ?? record.kind ?? record.status),
username: normalizeUsername(record),
avatar_url: normalizeAvatar(record),
detail: normalizeDetail(record),
timestamp: normalizeTimestamp(record),
};
}

function extractItems(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;

const record = asRecord(payload);
if (!record) return [];

const nested =
record.events ??
record.items ??
record.data ??
record.results ??
record.activity;

return Array.isArray(nested) ? nested : [];
}

export function normalizeActivityFeed(payload: unknown): ActivityEvent[] {
return extractItems(payload)
.map(normalizeEvent)
.filter((event): event is ActivityEvent => event !== null)
.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
}

export async function getActivityFeed(): Promise<ActivityEvent[]> {
const response = await apiClient<unknown>('/api/activity');
return normalizeActivityFeed(response);
}
Loading