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
6 changes: 6 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ jobs:
env:
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN: ${{ secrets.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

- name: Setup Node
uses: actions/setup-node@v4
Expand Down Expand Up @@ -198,6 +199,11 @@ jobs:
path: e2e/results.xml
reporter: java-junit

- name: Dump service logs on failure
if: failure()
working-directory: infra
run: docker compose --profile backend logs --tail=200

- name: Stop services
if: always()
working-directory: infra
Expand Down
123 changes: 123 additions & 0 deletions api/tests/video-extract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { test, expect } from "@playwright/test";
import {
API_URL,
createTestUser,
authHeaders,
} from "../fixtures/api-helpers.js";

test.describe("Video Metadata Extraction", () => {
test.describe("Boundary Tests", () => {
test("extract without auth returns 401/403", async ({ request }) => {
const response = await request.get(`${API_URL}/videos/extract`, {
params: {
youtubeUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
},
});

expect([401, 403]).toContain(response.status());
});

test("extract with invalid URL returns 400", async ({ request }) => {
const user = await createTestUser(request);

const response = await request.get(`${API_URL}/videos/extract`, {
params: { youtubeUrl: "not-a-valid-url" },
headers: authHeaders(user.accessToken),
});

expect(response.status()).toBe(400);
});

test("extract with unavailable video returns 422", async ({ request }) => {
const user = await createTestUser(request);

// Use a video ID that looks valid but doesn't exist
const response = await request.get(`${API_URL}/videos/extract`, {
params: {
youtubeUrl: "https://www.youtube.com/watch?v=XXXXXXXXXXX",
},
headers: authHeaders(user.accessToken),
});

expect(response.status()).toBe(422);
});
});

test.describe("Real Extraction", () => {
test("extract with valid URL returns valid extraction shape", async ({
request,
}) => {
const user = await createTestUser(request);

const response = await request.get(`${API_URL}/videos/extract`, {
params: {
// Known seed video: "1ST AMENDMENT AUDIT FEDEX PALM SPRINGS"
youtubeUrl: "https://www.youtube.com/watch?v=RngL8_3k0C0",
},
headers: authHeaders(user.accessToken),
});

expect(
response.status(),
`Expected 2xx but got ${response.status()}: ${await response.text()}`,
).toBe(200);
const body = await response.json();

// Validate response shape — do NOT assert specific values (non-deterministic)
expect(body.amendments).toBeDefined();
expect(Array.isArray(body.amendments)).toBe(true);

expect(body.participants).toBeDefined();
expect(Array.isArray(body.participants)).toBe(true);

// Validate enum values
const validAmendments = [
"FIRST",
"SECOND",
"FOURTH",
"FIFTH",
"FOURTEENTH",
];
for (const amendment of body.amendments) {
expect(validAmendments).toContain(amendment);
}

const validParticipants = [
"POLICE",
"GOVERNMENT",
"BUSINESS",
"CITIZEN",
"SECURITY",
];
for (const participant of body.participants) {
expect(validParticipants).toContain(participant);
}

// Confidence scores
expect(body.confidence).toBeDefined();
if (body.confidence) {
for (const key of [
"amendments",
"participants",
"videoDate",
"location",
]) {
if (body.confidence[key] != null) {
expect(body.confidence[key]).toBeGreaterThanOrEqual(0);
expect(body.confidence[key]).toBeLessThanOrEqual(1);
}
}
}

// videoDate is either null or a valid date string
if (body.videoDate != null) {
expect(body.videoDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}

// location is either null or an object with expected fields
if (body.location != null) {
expect(typeof body.location).toBe("object");
}
});
});
});
5 changes: 4 additions & 1 deletion api/tests/video-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@
});

if (!trustedLogin.ok()) {
test.skip(true, "Trusted seed user not available");

Check warning on line 193 in api/tests/video-service.spec.ts

View workflow job for this annotation

GitHub Actions / integration

Unexpected use of the `.skip()` annotation
return;
}

Expand Down Expand Up @@ -228,7 +228,10 @@
return;
}

expect(response.ok()).toBeTruthy();
expect(
response.status(),
`Expected 2xx but got ${response.status()}: ${await response.text()}`,
).toBe(201);
const video = await response.json();

// Auto-approval is async via SQS (video-service -> moderation-service -> video-service).
Expand Down Expand Up @@ -361,7 +364,7 @@
const found = listBody.content.find(
(v: { id: string }) => v.id === video.id,
);
expect(found).toMatchObject({

Check failure on line 367 in api/tests/video-service.spec.ts

View workflow job for this annotation

GitHub Actions / integration

[other-services] › api/tests/video-service.spec.ts:327:5 › Video Service API › Video Rejection Reason › owner can see rejection reason on their rejected videos

1) [other-services] › api/tests/video-service.spec.ts:327:5 › Video Service API › Video Rejection Reason › owner can see rejection reason on their rejected videos Error: expect(received).toMatchObject(expected) - Expected - 1 + Received + 0 Object { - "rejectionReason": null, "status": "PENDING", } 365 | (v: { id: string }) => v.id === video.id, 366 | ); > 367 | expect(found).toMatchObject({ | ^ 368 | status: "PENDING", 369 | rejectionReason: null, 370 | }); at /home/runner/work/AcctAtlas-integration-tests/AcctAtlas-integration-tests/api/tests/video-service.spec.ts:367:25
status: "PENDING",
rejectionReason: null,
});
Expand Down
95 changes: 95 additions & 0 deletions e2e/tests/videos/video-autofill.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test, expect } from "@playwright/test";
import { createTestUser, loginViaUI } from "../../fixtures/test-data";
import {
PAGE_LOAD_TIMEOUT,
UI_INTERACTION_TIMEOUT,
} from "../../fixtures/test-constants";

// Use a video that is NOT in the seed data so the form is shown (not "already exists")
const TEST_VIDEO_URL = "https://www.youtube.com/watch?v=kJQP7kiw5Fk";
const TEST_VIDEO_TITLE_FRAGMENT = "Despacito";

test.describe("Video Auto-fill with AI", () => {
test("Auto-fill button is not visible before preview", async ({
page,
request,
browserName,
}) => {
const user = await createTestUser(request);
expect(user.response.ok()).toBeTruthy();

await loginViaUI(page, user.email, user.password, browserName);
await page.goto("/videos/new");
await expect(page.getByText("Submit a Video")).toBeVisible({
timeout: PAGE_LOAD_TIMEOUT,
});

// Auto-fill button should not be visible before preview
await expect(page.getByText("Auto-fill with AI")).toBeHidden();
});

test("Auto-fill button appears after successful preview", async ({
page,
request,
browserName,
}) => {
const user = await createTestUser(request);
expect(user.response.ok()).toBeTruthy();

await loginViaUI(page, user.email, user.password, browserName);
await page.goto("/videos/new");
await expect(page.getByText("Submit a Video")).toBeVisible({
timeout: PAGE_LOAD_TIMEOUT,
});

// Enter a YouTube URL and click Preview
await page.getByPlaceholder(/youtube/i).fill(TEST_VIDEO_URL);
await page.getByRole("button", { name: "Preview" }).click();

// Wait for preview to load (shows video title)
await expect(page.getByText(TEST_VIDEO_TITLE_FRAGMENT)).toBeVisible({
timeout: PAGE_LOAD_TIMEOUT,
});

// Auto-fill button should now be visible
await expect(page.getByText("Auto-fill with AI")).toBeVisible();
});

test("Auto-fill completes extraction without error", async ({
page,
request,
browserName,
}) => {
const user = await createTestUser(request);
expect(user.response.ok()).toBeTruthy();

await loginViaUI(page, user.email, user.password, browserName);
await page.goto("/videos/new");
await expect(page.getByText("Submit a Video")).toBeVisible({
timeout: PAGE_LOAD_TIMEOUT,
});

// Enter a YouTube URL and click Preview
await page.getByPlaceholder(/youtube/i).fill(TEST_VIDEO_URL);
await page.getByRole("button", { name: "Preview" }).click();

// Wait for preview to load
await expect(page.getByText(TEST_VIDEO_TITLE_FRAGMENT)).toBeVisible({
timeout: PAGE_LOAD_TIMEOUT,
});

// Click Auto-fill with AI
await page.getByRole("button", { name: "Auto-fill with AI" }).click();

// Wait for extraction to complete — Claude API call may take several seconds
await expect(
page.getByRole("button", { name: "Auto-fill with AI" }),
).toBeEnabled({ timeout: 30_000 });

// Verify success toast appeared (not an error toast)
// Use period to distinguish toast ("applied.") from review banner ("applied —")
await expect(page.getByText("AI suggestions applied.")).toBeVisible({
timeout: UI_INTERACTION_TIMEOUT,
});
});
});
Loading