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
211 changes: 211 additions & 0 deletions packages/amara/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { AmaraAPIError, makeAmaraRequest } from './client';
import { AmaraEndpointOutputSchemas } from './endpoints/types';

/**
* Live contract tests — they call the real Amara API and therefore need a key.
*
* Run locally with:
* AMARA_API_KEY=... pnpm --filter @corsair-dev/amara test api.test.ts
*
* The suite skips itself when no key is present so a keyless local run stays green.
*/
jest.setTimeout(60_000);

const API_KEY = process.env.AMARA_API_KEY;
const describeLive = API_KEY ? describe : describe.skip;

const KNOWN_VIDEO_ID = 'vkMyJ7Ty7JgJ';
const KNOWN_VIDEO_URL = 'http://www.youtube.com/watch?v=AtmU1ZtPIlo';

describeLive('Amara API contract', () => {
const key = API_KEY as string;

it('users/me matches the user output schema', async () => {
const response = await makeAmaraRequest('users/me/', key);
const parsed = AmaraEndpointOutputSchemas.usersGetData.parse(response);
expect(parsed.id || parsed.username).toBeTruthy();
});

it('languages list matches the output schema', async () => {
const response = await makeAmaraRequest('languages/', key);
const parsed =
AmaraEndpointOutputSchemas.languagesListAvailable.parse(response);
expect(typeof parsed.languages).toBe('object');
expect(parsed.languages.en).toBe('English');
});

it('videos list matches the output schema', async () => {
const response = await makeAmaraRequest('videos/', key, {
query: { limit: 5 },
});
const parsed = AmaraEndpointOutputSchemas.videosList.parse(response);
expect(Array.isArray(parsed.objects)).toBe(true);
expect(typeof parsed.meta?.total_count).toBe('number');
});

it('video view details for a known video matches the schema', async () => {
const response = await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/`, key);
const parsed = AmaraEndpointOutputSchemas.videosViewDetails.parse(response);
expect(parsed.id).toBe(KNOWN_VIDEO_ID);
});

it('subtitle languages for a known video match the schema', async () => {
const response = await makeAmaraRequest(
`videos/${KNOWN_VIDEO_ID}/languages/`,
key,
);
const parsed =
AmaraEndpointOutputSchemas.videosListSubtitleLanguages.parse(response);
expect(Array.isArray(parsed.objects)).toBe(true);
});

it('fetch subtitles for a known video language matches the schema', async () => {
const languages = await makeAmaraRequest<{
objects?: Array<{ language_code?: string }>;
}>(`videos/${KNOWN_VIDEO_ID}/languages/`, key);

const languageCode = languages.objects?.[0]?.language_code ?? 'en';
const response = await makeAmaraRequest(
`videos/${KNOWN_VIDEO_ID}/languages/${languageCode}/subtitles/`,
key,
{ query: { sub_format: 'json' } },
);
const parsed =
AmaraEndpointOutputSchemas.videosFetchSubtitlesData.parse(response);
expect(
parsed.sub_format === undefined || typeof parsed.sub_format === 'string',
).toBe(true);
});

it('teams list/details/languages match schemas', async () => {
const list = AmaraEndpointOutputSchemas.teamsList.parse(
await makeAmaraRequest('teams/', key, { query: { limit: 2 } }),
);
expect(Array.isArray(list.objects)).toBe(true);

const details = AmaraEndpointOutputSchemas.teamsGetDetails.parse(
await makeAmaraRequest('teams/ability/', key),
);
expect(details.slug).toBe('ability');

const languages = AmaraEndpointOutputSchemas.teamsGetLanguages.parse(
await makeAmaraRequest('teams/ability/languages/', key),
);
expect(
typeof languages.preferred === 'string' ||
languages.preferred === undefined,
).toBe(true);
});

it('activity list/get match schemas', async () => {
const list = AmaraEndpointOutputSchemas.activityList.parse(
await makeAmaraRequest('activity/', key, { query: { limit: 2 } }),
);
expect(Array.isArray(list.objects)).toBe(true);
const id = list.objects?.[0]?.id;
expect(id).toBeTruthy();

const one = AmaraEndpointOutputSchemas.activityGet.parse(
await makeAmaraRequest(`activity/${id}/`, key),
);
expect(one.id).toBe(id);
});

it('video urls list/get and url lookup match schemas', async () => {
const urls = AmaraEndpointOutputSchemas.videosListUrls.parse(
await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/urls/`, key),
);
expect((urls.objects?.length ?? 0) > 0).toBe(true);
const urlId = urls.objects?.[0]?.id;
expect(typeof urlId).toBe('number');

const one = AmaraEndpointOutputSchemas.videosGetUrl.parse(
await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/urls/${urlId}/`, key),
);
expect(one.id).toBe(urlId);

const byUrl = AmaraEndpointOutputSchemas.videosGetUrlDetails.parse(
await makeAmaraRequest('videos/', key, {
query: { video_url: KNOWN_VIDEO_URL, limit: 1 },
}),
);
expect(byUrl.objects?.[0]?.id).toBe(KNOWN_VIDEO_ID);
});

it('subtitle language details and video activity match schemas', async () => {
const lang =
AmaraEndpointOutputSchemas.videosGetSubtitleLanguageDetails.parse(
await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/languages/en/`, key),
);
expect(lang.language_code).toBe('en');

const activity = AmaraEndpointOutputSchemas.videosListActivity.parse(
await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/activity/`, key, {
query: { limit: 2 },
}),
);
expect(Array.isArray(activity.objects)).toBe(true);
});

it('users.getData accepts id$ identifiers without mangling $', async () => {
const me = AmaraEndpointOutputSchemas.usersGetData.parse(
await makeAmaraRequest('users/me/', key),
);
expect(me.id).toBeTruthy();

const byId = AmaraEndpointOutputSchemas.usersGetData.parse(
await makeAmaraRequest(`users/id$${me.id}/`, key),
);
expect(byId.id).toBe(me.id);
});

it('users.getActivity is available when Amara exposes it for the user', async () => {
// Live user payloads currently omit activity_uri and /activity/ often
// 404s for this key — still verify the path + schema when it works.
const me = AmaraEndpointOutputSchemas.usersGetData.parse(
await makeAmaraRequest('users/me/', key),
);
try {
const activity = AmaraEndpointOutputSchemas.usersGetActivity.parse(
await makeAmaraRequest(`users/id$${me.id}/activity/`, key, {
query: { limit: 2 },
}),
);
expect(activity.meta || Array.isArray(activity.objects)).toBeTruthy();
} catch (error) {
expect(String(error)).toMatch(/404|Not Found/i);
}
});

it('videos.create reports team requirement clearly for this API key', async () => {
// This Amara account requires a team on create; public teams reject us.
let createdId: string | undefined;
try {
const created = AmaraEndpointOutputSchemas.videosCreate.parse(
await makeAmaraRequest('videos/', key, {
method: 'POST',
body: {
video_url: 'https://www.youtube.com/watch?v=BaW_jenozKc',
title: 'Corsair Amara plugin smoke',
primary_audio_language_code: 'en',
},
}),
);
createdId = created.id;
expect(created.id).toBeTruthy();
} catch (error) {
expect(error).toBeInstanceOf(AmaraAPIError);
const amaraError = error as AmaraAPIError;
expect(amaraError.status).toBe(400);
expect(JSON.stringify(amaraError.body ?? amaraError.message)).toMatch(
/Team is required/i,
);
} finally {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (createdId) {
await makeAmaraRequest(`videos/${createdId}/`, key, {
method: 'DELETE',
});
}
}
});
});
138 changes: 138 additions & 0 deletions packages/amara/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';
import {
AMARA_API_BASE,
AmaraAPIError,
encodeAmaraPathSegment,
makeAmaraRequest,
} from './client';

jest.mock('corsair/http', () => {
const actual = jest.requireActual('corsair/http');
return { ...actual, request: jest.fn() };
});

const mockRequest = request as jest.MockedFunction<typeof request>;

function lastCall(): [OpenAPIConfig, ApiRequestOptions] {
const call = mockRequest.mock.calls.at(-1);
if (!call) throw new Error('request() was never called');
return call as unknown as [OpenAPIConfig, ApiRequestOptions];
}

function apiError(status: number, retryAfter?: number): ApiError {
return new ApiError(
{ method: 'GET', url: 'videos/' },
{
url: `${AMARA_API_BASE}/videos/`,
ok: false,
status,
statusText: 'Error',
body: { detail: 'failed' },
},
'Amara request failed',
{ retryAfter },
);
}

beforeEach(() => {
mockRequest.mockReset();
});

describe('makeAmaraRequest', () => {
it('sends the API key in the X-api-key header and never as a bearer token', async () => {
mockRequest.mockResolvedValue({ objects: [] });

await makeAmaraRequest('videos/', 'secret-key', {
query: { limit: 5 },
});

const [config] = lastCall();
expect(config.BASE).toBe(AMARA_API_BASE);
expect(config.HEADERS).toMatchObject({ 'X-api-key': 'secret-key' });
expect(config.TOKEN).toBeUndefined();
});

it('issues GET with path and query parameters', async () => {
mockRequest.mockResolvedValue({ objects: [] });

await makeAmaraRequest('videos/', 'k', {
query: { team: 'ability', limit: 10 },
});

const [, options] = lastCall();
expect(options.method).toBe('GET');
expect(options.url).toBe('videos/');
expect(options.query).toEqual({ team: 'ability', limit: 10 });
});

it('issues POST with a JSON body', async () => {
mockRequest.mockResolvedValue({ id: 'abc' });

await makeAmaraRequest('videos/', 'k', {
method: 'POST',
body: { video_url: 'https://example.com/v.mp4', title: 'Hi' },
});

const [, options] = lastCall();
expect(options.method).toBe('POST');
expect(options.body).toEqual({
video_url: 'https://example.com/v.mp4',
title: 'Hi',
});
});

it('normalises an empty DELETE body to { ok: true }', async () => {
mockRequest.mockResolvedValue(undefined);

const result = await makeAmaraRequest('videos/x/urls/1/', 'k', {
method: 'DELETE',
});

expect(result).toEqual({ ok: true });
});

it('wraps an ApiError in AmaraAPIError, preserving status and cause', async () => {
expect.assertions(4);
const original = apiError(429, 1500);
mockRequest.mockRejectedValue(original);

try {
await makeAmaraRequest('videos/', 'k');
throw new Error('expected makeAmaraRequest to throw');
} catch (error) {
const amaraError = error as AmaraAPIError;
expect(amaraError.status).toBe(429);
expect(amaraError.code).toBe(429);
expect(amaraError.retryAfter).toBe(1500);
expect(amaraError.cause).toBe(original);
}
});

it('wraps a non-ApiError failure without inventing a status', async () => {
mockRequest.mockRejectedValue(new Error('socket hang up'));

try {
await makeAmaraRequest('videos/', 'k');
throw new Error('expected makeAmaraRequest to throw');
} catch (error) {
const amaraError = error as AmaraAPIError;
expect(amaraError).toBeInstanceOf(AmaraAPIError);
expect(amaraError.message).toBe('socket hang up');
expect(amaraError.status).toBeUndefined();
}
});
});

describe('AMARA_API_BASE', () => {
it('points at the official Amara API host', () => {
expect(AMARA_API_BASE).toBe('https://amara.org/api');
});
});

describe('encodeAmaraPathSegment', () => {
it('keeps id$ user identifiers literal while encoding unsafe chars', () => {
expect(encodeAmaraPathSegment('id$abc_123')).toBe('id$abc_123');
expect(encodeAmaraPathSegment('a/b')).toBe('a%2Fb');
});
});
Loading
Loading