Skip to content
Draft
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
7 changes: 6 additions & 1 deletion docker-compose.test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ services:
POSTGRES_DB: maproulette
POSTGRES_USER: maproulette
POSTGRES_PASSWORD: hunter2
# No fixed host port: the backend reaches this over the compose network via
# the `db` hostname, and a fixed "5432:5432" mapping collides with any other
# local Postgres already bound to that port on the host. Publish without a
# host port so Docker picks a free one if you need to connect directly
# (`docker compose -f docker-compose.test.yaml port db 5432`).
ports:
- "5432:5432"
- "5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
Expand Down
68 changes: 68 additions & 0 deletions e2e/challenge-management.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, test } from './fixtures'

test('a user can edit an existing challenge and see the changes persisted', async ({
page,
challenge,
}) => {
await page.goto(`/manage/challenge/${challenge.id}/edit`)

const heading = page.getByRole('heading', { name: 'Challenge Editor' })
await expect(heading).toBeVisible({ timeout: 15_000 })

const nameField = page.getByLabel('Name')
// The form is populated asynchronously once the challenge query resolves.
await expect(nameField).toHaveValue(challenge.name, { timeout: 15_000 })

const updatedName = `${challenge.name} (edited)`
const updatedDescription = 'Updated by the challenge-management E2E test.'

await nameField.fill(updatedName)
await page.getByLabel('Description').fill(updatedDescription)

await page.getByRole('button', { name: 'Update Challenge' }).click()

await expect(page).toHaveURL(new RegExp(`/manage/challenge/${challenge.id}$`), {
timeout: 30_000,
})
// The updated name renders in both a top toolbar heading and the page's
// main heading; either confirms the edit persisted and reloaded correctly.
await expect(page.getByRole('heading', { name: updatedName }).first()).toBeVisible({
timeout: 15_000,
})

await page.getByRole('button', { name: 'Description' }).click()
await expect(page.getByRole('dialog')).toContainText(updatedDescription)
})

test('a user can archive an existing challenge and see it flagged as archived', async ({
page,
challenge,
}) => {
await page.goto(`/manage/project/${challenge.projectId}`)

await expect(page.getByText(challenge.name)).toBeVisible({ timeout: 20_000 })

// The table view keeps row actions in their own cell (rather than nested
// inside the challenge's link, as in grid/card view), so switch to it.
await page.getByRole('radio', { name: 'List view' }).click()

const row = page.getByRole('row', { name: challenge.name })
await expect(row).toBeVisible({ timeout: 15_000 })

await row.getByRole('button', { name: 'Open menu' }).click()
await page.getByRole('menuitem', { name: 'Archive challenge' }).click()

// Once the archive mutation resolves and the challenge list is refetched,
// the same menu item is relabeled to offer the reverse action.
await row.getByRole('button', { name: 'Open menu' }).click()
await expect(page.getByRole('menuitem', { name: 'Unarchive challenge' })).toBeVisible({
timeout: 15_000,
})
await page.keyboard.press('Escape')

// The "Archived" filter only shows challenges with isArchived set, so the
// challenge remaining visible after switching it on confirms the archived
// state took effect server-side, not just in the dropdown label.
await page.getByRole('switch', { name: 'Archived' }).click()
await expect(row).toBeVisible({ timeout: 15_000 })
})
18 changes: 18 additions & 0 deletions e2e/challenge-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, test } from './fixtures'

test('a user can find a challenge by name using the header search bar', async ({
page,
challenge,
}) => {
await page.goto('/')

const searchInput = page.getByRole('searchbox', {
name: /search for challenges, tasks or projects/i,
})
await searchInput.click()
await searchInput.fill(challenge.name)

await expect(page.getByRole('heading', { name: challenge.name })).toBeVisible({
timeout: 15_000,
})
})
101 changes: 93 additions & 8 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,26 @@ import { type APIRequestContext, test as base } from '@playwright/test'
const BACKEND_URL = 'http://localhost:9000'
const SUPER_KEY = 'super-secret-key'

const uniqueName = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`

export interface TestProject {
id: number
name: string
}

export interface TestChallenge {
id: number
name: string
projectId: number
}

export interface TestTask {
id: number
name: string
challengeId: number
}

async function createProject(request: APIRequestContext, name: string): Promise<TestProject> {
const response = await request.post(`${BACKEND_URL}/api/v2/project`, {
headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' },
Expand Down Expand Up @@ -38,13 +53,83 @@ async function deleteProject(request: APIRequestContext, id: number): Promise<vo
}
}

export const test = base.extend<{ project: TestProject }>({
project: async ({ request }, use) => {
const name = `e2e-project-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`
const project = await createProject(request, name)
await use(project)
await deleteProject(request, project.id)
},
})
// Note: creating a challenge with `localGeoJSON`, or uploading tasks via the
// addFileTasks endpoint, does not reliably produce tasks against the pinned
// backend image (a server-side Scala collections bug silently fails async
// task import). Creating the challenge shell and its tasks directly via their
// own JSON-body endpoints (below) sidesteps that entirely and is immediate.
async function createChallenge(
request: APIRequestContext,
projectId: number,
name: string
): Promise<TestChallenge> {
const response = await request.post(`${BACKEND_URL}/api/v2/challenge`, {
headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' },
data: {
parent: projectId,
name,
description: 'E2E test challenge',
instruction: 'Fix the identified issue.',
difficulty: 2,
enabled: true,
featured: false,
overpassQL: '',
overpassTargetType: '',
},
})
if (!response.ok()) {
throw new Error(`Failed to create challenge: ${response.status()} ${await response.text()}`)
}
const body = (await response.json()) as { id: number }
return { id: body.id, name, projectId }
}

async function createTask(
request: APIRequestContext,
challengeId: number,
name: string,
coordinates: [number, number] = [-95.454772, 37.6866588]
): Promise<TestTask> {
const response = await request.post(`${BACKEND_URL}/api/v2/task`, {
headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' },
data: {
name,
parent: challengeId,
instruction: 'Fix this point.',
geometries: {
type: 'FeatureCollection',
features: [{ type: 'Feature', geometry: { type: 'Point', coordinates }, properties: {} }],
},
priority: 0,
},
})
if (!response.ok()) {
throw new Error(`Failed to create task: ${response.status()} ${await response.text()}`)
}
const body = (await response.json()) as { id: number }
return { id: body.id, name, challengeId }
}

export const test = base.extend<{ project: TestProject; challenge: TestChallenge; task: TestTask }>(
{
project: async ({ request }, use) => {
const project = await createProject(request, uniqueName('e2e-project'))
await use(project)
await deleteProject(request, project.id)
},

// Deleting `project` (above) cascades to its challenges and tasks on the
// backend, so neither fixture below needs its own teardown.
challenge: async ({ request, project }, use) => {
const challenge = await createChallenge(request, project.id, uniqueName('e2e-challenge'))
await use(challenge)
},

task: async ({ request, challenge }, use) => {
const task = await createTask(request, challenge.id, uniqueName('e2e-task'))
await use(task)
},
}
)

export { expect } from '@playwright/test'
54 changes: 54 additions & 0 deletions e2e/profile-settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from './fixtures'

const BACKEND_URL = 'http://localhost:9000'
const SUPER_KEY = 'super-secret-key'

interface WhoamiUser {
id: number
settings: {
defaultBasemapId?: string | null
[key: string]: unknown
}
}

// Note: this test does NOT reload the page to verify the setting survived a
// fresh fetch. The test harness authenticates as a synthetic backend user
// (id -999, via the MR_SUPER_KEY apiKey header) that isn't a real persisted
// DB row — every GET /whoami synthesizes a fresh default user from scratch,
// so a reload always reports defaults regardless of any PUT that came before,
// for any field. That's an inherent limitation of this synthetic identity,
// not something a real logged-in user would hit. What IS genuinely provable
// here: submitting the form calls the real update mutation (confirmed via the
// success toast, which only fires in the mutation's resolved `.then`), and
// the query cache it seeds keeps the saved value showing in the UI
// immediately afterward, without a reload.
test('a user can update their custom basemap URL setting', async ({ page, request }) => {
const whoamiResponse = await request.get(`${BACKEND_URL}/api/v2/user/whoami`, {
headers: { apiKey: SUPER_KEY },
})
expect(whoamiResponse.ok()).toBeTruthy()
const originalUser = (await whoamiResponse.json()) as WhoamiUser

const newBasemapUrl = `https://example.com/tiles/${Date.now()}/{z}/{x}/{y}.png`

await page.goto('/settings')

const basemapUrlField = page.getByLabel('Custom Basemap URL')
await expect(basemapUrlField).toBeVisible({ timeout: 15_000 })

await basemapUrlField.fill(newBasemapUrl)

const submit = page.getByRole('button', { name: 'Submit' })
await submit.click()

await expect(page.getByText('User settings updated')).toBeVisible({ timeout: 15_000 })
await expect(basemapUrlField).toHaveValue(newBasemapUrl)

// Restoring the synthetic user's settings on the real backend is a no-op
// (see note above), but this stays as a formality in case the harness ever
// points at a real persisted account.
await request.put(`${BACKEND_URL}/api/v2/user/${originalUser.id}`, {
headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' },
data: originalUser.settings,
})
})
58 changes: 58 additions & 0 deletions e2e/super-admin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, test } from './fixtures'

const BACKEND_URL = 'http://localhost:9000'
const SUPER_KEY = 'super-secret-key'

interface WhoamiUser {
id: number
osmProfile: {
displayName: string
}
}

// Skipped: the test harness authenticates every request as a synthetic
// backend user (id -999) via the MR_SUPER_KEY apiKey header. That grants the
// backend API full access, and PUT /superuser/-999 does add it to the
// /superusers list — but src/lib/SuperAdminGuard.tsx (the frontend's route
// guard for /super-admin/*) checks for a `grants` entry with role === -1,
// and whoami's `grants` array for this synthetic user stays empty regardless
// (confirmed: no exposed API grants a global role to it). So the frontend
// itself renders "Access Denied" for any /super-admin/* route in this
// environment, independent of anything in this repo — there's currently no
// way to represent a real super-admin-granted user without OSM OAuth or
// direct database access, neither of which the E2E harness uses.
test.skip('the super-admin users page lists the real authenticated user from the backend', async ({
page,
request,
}) => {
// The whole browser session authenticates as this real backend user via the
// apiKey header (see e2e/fixtures.ts / .env.test), so it's a genuine row the
// super-admin users list must render from the database, not a fixture we set up.
const whoamiResponse = await request.get(`${BACKEND_URL}/api/v2/user/whoami`, {
headers: { apiKey: SUPER_KEY },
})
expect(whoamiResponse.ok()).toBeTruthy()
const currentUser = (await whoamiResponse.json()) as WhoamiUser

await page.goto('/super-admin/users')

await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible({
timeout: 15_000,
})

const userRow = page.getByRole('row', { name: currentUser.osmProfile.displayName })
await expect(userRow).toBeVisible({ timeout: 15_000 })
await expect(userRow.getByText(`ID: ${currentUser.id}`)).toBeVisible()
await expect(userRow.getByText('super admin')).toBeVisible()

const searchInput = page.getByPlaceholder('Search users by name or email...')

// Filtering by the real display name keeps the row visible.
await searchInput.fill(currentUser.osmProfile.displayName)
await expect(userRow).toBeVisible()

// An unmatched search filters the row out and shows the empty state.
await searchInput.fill('no-such-user-xyz-123')
await expect(userRow).not.toBeVisible()
await expect(page.getByText('No users found')).toBeVisible({ timeout: 15_000 })
})
44 changes: 44 additions & 0 deletions e2e/task-workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from './fixtures'

test('a user can open a task, view its details, and mark it as fixed', async ({
page,
task,
challenge,
}) => {
test.setTimeout(60_000)

await page.goto(`/tasks/${task.id}`)

// Task details are visible: the task identity and the challenge's instructions
// (the task page shows the challenge's instruction text, substituted with task
// properties, in its "Task" instruction tab).
await expect(page.getByText(`Task #${task.id}`).first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Fix the identified issue.')).toBeVisible({ timeout: 15_000 })

// The task auto-locks for mapping shortly after the page loads, at which point
// the completion action buttons replace the "Map this task" prompt.
const fixedButton = page.getByRole('button', { name: 'Fixed', exact: true })
await expect(fixedButton).toBeVisible({ timeout: 20_000 })
await fixedButton.click()

// Confirm the status change in the completion modal
await expect(page.getByRole('heading', { name: 'Complete Task Action' })).toBeVisible({
timeout: 10_000,
})
await page.getByRole('button', { name: 'Complete & Continue' }).click()

// A success toast confirms the status change was applied
await expect(page.getByText('Task marked as Fixed')).toBeVisible({ timeout: 15_000 })

// This is the only task in the challenge, so once it's Fixed there is no
// other task to load next, and the app returns to the challenge page.
await expect(page.getByText('No more tasks available in this challenge')).toBeVisible({
timeout: 15_000,
})
// The map view appends a `#zoom/lat/lng` hash once it settles, so match the
// path rather than requiring an exact end-of-string.
await expect(page).toHaveURL(new RegExp(`/challenge/${challenge.id}(#|$)`), { timeout: 20_000 })
await expect(page.getByRole('heading', { name: challenge.name })).toBeVisible({
timeout: 15_000,
})
})
Loading
Loading