Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ jobs:
- name: Install deps
run: npm ci

- name: Run tests
run: npm run test:run

- name: Verify project builds
run: npm run build
30 changes: 0 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
SourceSummary,
} from '../types';

class LightcurveApiClient {
export class LightcurveApiClient {
private baseUrl: string;
private fluxUrlStub: string;

Expand Down
25 changes: 21 additions & 4 deletions tests/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
import { MemoryRouter } from 'react-router';
import { render, screen } from '@testing-library/react';
import { describe, it } from 'vitest';
import { describe, it, expect } from 'vitest';

import App from '../src/App';

describe('App', () => {
it('renders App component', () => {
it('renders navigation and footer on unmatched routes', () => {
render(
<MemoryRouter>
<MemoryRouter initialEntries={['/some/unmatched/route']}>
<App />
</MemoryRouter>
);

screen.debug();
expect(
screen.getByRole('link', { name: /SO Light Curve Viewer/i })
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: /the documentation/i })
).toBeInTheDocument();
});

it('renders the not-found page for an unmatched route', () => {
render(
<MemoryRouter initialEntries={['/some/unmatched/route']}>
<App />
</MemoryRouter>
);

expect(
screen.getByRole('heading', { name: /page not found/i })
).toBeInTheDocument();
});
});
171 changes: 171 additions & 0 deletions tests/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';

import { LightcurveApiClient } from '../../src/api/client';

const BASE_URL = 'http://test.api';

function jsonResponse(data: unknown, ok = true, status = 200) {
return {
ok,
status,
json: () => Promise.resolve(data),
} as unknown as Response;
}

function blobResponse(ok = true, status = 200) {
return {
ok,
status,
blob: () => Promise.resolve(new Blob(['data'])),
} as unknown as Response;
}

describe('LightcurveApiClient', () => {
let client: LightcurveApiClient;
let fetchMock: Mock<typeof fetch>;
let revokeObjectURLMock: Mock<(url: string) => void>;

beforeEach(() => {
client = new LightcurveApiClient(BASE_URL);
fetchMock = vi.fn<typeof fetch>();
global.fetch = fetchMock;
URL.createObjectURL = vi
.fn<() => string>()
.mockReturnValue('blob:mock-url');
revokeObjectURLMock = vi.fn<(url: string) => void>();
URL.revokeObjectURL = revokeObjectURLMock;
// jsdom attempts real navigation on anchor clicks, which logs noisy
// "Not implemented" errors; downloads aren't real navigation anyway.
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('GET endpoints', () => {
it('fetches sources from /sources/', async () => {
const sources = [{ source_id: '1' }];
fetchMock.mockResolvedValueOnce(jsonResponse(sources));

const result = await client.getSources();

expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/sources/`);
expect(result).toEqual(sources);
});

it('fetches a single source by id', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ source_id: '42' }));

await client.getSourceData('42');

expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/sources/42`);
});

it('fetches a source summary', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}));

await client.getSourceSummary('42');

expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/sources/42/summary`);
});

it('fetches nearby sources via a cone search query string', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([]));

await client.getNearbySources('?ra=1&dec=2&radius=0.5');

expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/sources/cone?ra=1&dec=2&radius=0.5`
);
});

it('fetches the sources feed with a start offset', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ items: [] }));

await client.getSourcesFeed(10);

expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/sources/feed?start=10`
);
});

it('fetches lightcurve data with a selection strategy', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}));

await client.getLightcurveData('42', 'frequency');

expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/lightcurves/42/unbinned?selection_strategy=frequency`
);
});

it('throws when a GET request fails', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(null, false, 500));

await expect(client.getSourceData('42')).rejects.toThrow(
'GET /sources/42 failed: 500'
);
});
});

describe('downloadCutout', () => {
it('fetches the cutout and triggers a download with the expected filename', async () => {
fetchMock.mockResolvedValueOnce(blobResponse());
const appendChildSpy = vi.spyOn(document.body, 'appendChild');

await client.downloadCutout('src-1', 'meas-1', 'png');

expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/cutouts/flux/src-1/meas-1?ext=png`
);

const anchor = appendChildSpy.mock.calls
.map(([node]) => node)
.find(
(node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement
);

expect(anchor?.download).toBe('cutout-src-1-meas-1.png');
expect(anchor?.href).toBe('blob:mock-url');
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url');
});

it('throws when the cutout fetch fails', async () => {
fetchMock.mockResolvedValueOnce(blobResponse(false, 404));

await expect(
client.downloadCutout('src-1', 'meas-1', 'png')
).rejects.toThrow('Failed to get cutout: 404');
});
});

describe('downloadTableData', () => {
it('fetches table data and triggers a download without a measurement id', async () => {
fetchMock.mockResolvedValueOnce(blobResponse());
const appendChildSpy = vi.spyOn(document.body, 'appendChild');

await client.downloadTableData('src-1', 'csv');

expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/lightcurves/src-1/all/download?format=csv`
);

const anchor = appendChildSpy.mock.calls
.map(([node]) => node)
.find(
(node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement
);

expect(anchor?.download).toBe('source-data-src-1.csv');
});
});
});
19 changes: 19 additions & 0 deletions tests/components/Badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';

import { Badge } from '../../src/components/Badge';

describe('Badge', () => {
it('renders the label and content', () => {
render(<Badge label="RA" content="123.456" />);

expect(screen.getByRole('heading', { name: 'RA' })).toBeInTheDocument();
expect(screen.getByText('123.456')).toBeInTheDocument();
});

it('renders ReactNode content, not just strings', () => {
render(<Badge label="Status" content={<span>Active</span>} />);

expect(screen.getByText('Active')).toBeInTheDocument();
});
});
Loading
Loading