321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
/**
|
|
* E2E tests for worksheet parsing flow
|
|
*
|
|
* Tests the complete flow of uploading, parsing, reviewing, and approving
|
|
* worksheet photos. Uses mocked SSE responses to avoid actual LLM calls.
|
|
*/
|
|
|
|
import { expect, test, type APIRequestContext } from '@playwright/test'
|
|
|
|
test.describe('Worksheet Parsing Flow', () => {
|
|
// Helper to create a player with a session for testing
|
|
async function createPlayerWithSession(request: APIRequestContext) {
|
|
// Create a player
|
|
const createPlayerRes = await request.post('/api/players', {
|
|
data: { name: 'Worksheet Test Child', emoji: '📝', color: '#4CAF50' },
|
|
})
|
|
expect(createPlayerRes.ok()).toBeTruthy()
|
|
const { player } = await createPlayerRes.json()
|
|
|
|
// Enable skills
|
|
await request.put(`/api/curriculum/${player.id}/skills`, {
|
|
data: {
|
|
masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead'],
|
|
},
|
|
})
|
|
|
|
// Create a session plan
|
|
const createPlanRes = await request.post(`/api/curriculum/${player.id}/sessions/plans`, {
|
|
data: { durationMinutes: 5 },
|
|
})
|
|
expect(createPlanRes.ok()).toBeTruthy()
|
|
const { plan } = await createPlanRes.json()
|
|
|
|
return { player, plan }
|
|
}
|
|
|
|
// Helper to clean up test data
|
|
async function cleanup(request: APIRequestContext, playerId: string) {
|
|
await request.delete(`/api/players/${playerId}`)
|
|
}
|
|
|
|
test.describe('Attachment API', () => {
|
|
test('can create and retrieve attachments for a session', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player, plan } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
// Get attachments (should be empty)
|
|
const getAttachmentsRes = await request.get(
|
|
`/api/curriculum/${player.id}/sessions/${plan.id}/attachments`
|
|
)
|
|
expect(getAttachmentsRes.ok()).toBeTruthy()
|
|
const { attachments } = await getAttachmentsRes.json()
|
|
expect(attachments).toEqual([])
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('Parse Review API', () => {
|
|
test('review endpoint returns 404 for non-existent attachment', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
const reviewRes = await request.patch(
|
|
`/api/curriculum/${player.id}/attachments/non-existent-id/review`,
|
|
{
|
|
data: {
|
|
corrections: [],
|
|
markAsReviewed: true,
|
|
},
|
|
}
|
|
)
|
|
expect(reviewRes.status()).toBe(404)
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('Authorization', () => {
|
|
test("unrelated user cannot access another user's attachments", async ({ browser }) => {
|
|
const userAContext = await browser.newContext()
|
|
const userBContext = await browser.newContext()
|
|
|
|
try {
|
|
// User A setup
|
|
const userAPage = await userAContext.newPage()
|
|
await userAPage.goto('/')
|
|
await userAPage.waitForLoadState('networkidle')
|
|
const userARequest = userAPage.request
|
|
|
|
// User B setup
|
|
const userBPage = await userBContext.newPage()
|
|
await userBPage.goto('/')
|
|
await userBPage.waitForLoadState('networkidle')
|
|
const userBRequest = userBPage.request
|
|
|
|
// User A creates player and session
|
|
const { player, plan } = await createPlayerWithSession(userARequest)
|
|
|
|
try {
|
|
// User B tries to access User A's session attachments (should fail)
|
|
const attackRes = await userBRequest.get(
|
|
`/api/curriculum/${player.id}/sessions/${plan.id}/attachments`
|
|
)
|
|
expect(attackRes.status()).toBe(403)
|
|
expect((await attackRes.json()).error).toBe('Not authorized')
|
|
} finally {
|
|
await userARequest.delete(`/api/players/${player.id}`)
|
|
}
|
|
} finally {
|
|
await userAContext.close()
|
|
await userBContext.close()
|
|
}
|
|
})
|
|
|
|
test("unrelated user cannot trigger parse on another user's attachment", async ({
|
|
browser,
|
|
}) => {
|
|
const userAContext = await browser.newContext()
|
|
const userBContext = await browser.newContext()
|
|
|
|
try {
|
|
const userAPage = await userAContext.newPage()
|
|
await userAPage.goto('/')
|
|
await userAPage.waitForLoadState('networkidle')
|
|
const userARequest = userAPage.request
|
|
|
|
const userBPage = await userBContext.newPage()
|
|
await userBPage.goto('/')
|
|
await userBPage.waitForLoadState('networkidle')
|
|
const userBRequest = userBPage.request
|
|
|
|
const { player } = await createPlayerWithSession(userARequest)
|
|
|
|
try {
|
|
// User B tries to trigger parse (should fail)
|
|
const attackRes = await userBRequest.post(
|
|
`/api/curriculum/${player.id}/attachments/fake-attachment-id/parse/stream`,
|
|
{
|
|
data: {},
|
|
}
|
|
)
|
|
expect(attackRes.status()).toBe(403)
|
|
} finally {
|
|
await userARequest.delete(`/api/players/${player.id}`)
|
|
}
|
|
} finally {
|
|
await userAContext.close()
|
|
await userBContext.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('UI State Management', () => {
|
|
test('practice summary page loads without errors', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player, plan } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
// Navigate to practice summary
|
|
const summaryUrl = `/practice/${player.id}/summary`
|
|
const response = await page.goto(summaryUrl)
|
|
expect(response?.status()).toBe(200)
|
|
|
|
// Check page loaded
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// The page should load without JavaScript errors
|
|
const errors: string[] = []
|
|
page.on('pageerror', (error) => errors.push(error.message))
|
|
|
|
// Wait a bit for any async errors
|
|
await page.waitForTimeout(1000)
|
|
|
|
// Filter out known non-critical errors
|
|
const criticalErrors = errors.filter(
|
|
(e) => !e.includes('ResizeObserver') && !e.includes('hydration')
|
|
)
|
|
expect(criticalErrors).toEqual([])
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
|
|
test('offline work section renders when photos exist', async ({ page }) => {
|
|
// This test verifies the UI component structure is correct
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
// Navigate to summary page
|
|
await page.goto(`/practice/${player.id}/summary`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Look for offline work section (it should exist even if empty)
|
|
// The section might not be visible if there are no attachments
|
|
// but the component should be mounted
|
|
|
|
// Check there are no critical console errors
|
|
const errors: string[] = []
|
|
page.on('pageerror', (error) => errors.push(error.message))
|
|
await page.waitForTimeout(500)
|
|
|
|
const criticalErrors = errors.filter(
|
|
(e) =>
|
|
!e.includes('ResizeObserver') &&
|
|
!e.includes('hydration') &&
|
|
!e.includes('useWorksheetParsingContext')
|
|
)
|
|
expect(criticalErrors).toEqual([])
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('Parse API Validation', () => {
|
|
test('parse stream endpoint validates attachment ownership', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
// Try to parse non-existent attachment
|
|
const parseRes = await request.post(
|
|
`/api/curriculum/${player.id}/attachments/non-existent/parse/stream`,
|
|
{ data: {} }
|
|
)
|
|
|
|
// Should return 404 (attachment not found)
|
|
expect(parseRes.status()).toBe(404)
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
|
|
test('parse-selected endpoint validates problem indices', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
// Try selective reparse with invalid data
|
|
const reparseRes = await request.post(
|
|
`/api/curriculum/${player.id}/attachments/non-existent/parse-selected/stream`,
|
|
{
|
|
data: {
|
|
problemIndices: [0, 1],
|
|
boundingBoxes: [
|
|
{ x: 0.1, y: 0.1, width: 0.2, height: 0.2 },
|
|
// Missing second bounding box - should fail validation
|
|
],
|
|
},
|
|
}
|
|
)
|
|
|
|
// Should return 400 (validation error) or 404 (not found)
|
|
expect([400, 404]).toContain(reparseRes.status())
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('Approve/Unapprove Flow', () => {
|
|
test('approve endpoint returns 404 for non-existent attachment', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
const approveRes = await request.post(
|
|
`/api/curriculum/${player.id}/attachments/non-existent/approve`
|
|
)
|
|
expect(approveRes.status()).toBe(404)
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
|
|
test('unapprove endpoint returns 404 for non-existent attachment', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
const request = page.request
|
|
|
|
const { player } = await createPlayerWithSession(request)
|
|
|
|
try {
|
|
const unapproveRes = await request.post(
|
|
`/api/curriculum/${player.id}/attachments/non-existent/unapprove`
|
|
)
|
|
expect(unapproveRes.status()).toBe(404)
|
|
} finally {
|
|
await cleanup(request, player.id)
|
|
}
|
|
})
|
|
})
|
|
})
|