diff --git a/tests/propose.spec.ts b/tests/propose.spec.ts new file mode 100644 index 0000000..1bfc0bc --- /dev/null +++ b/tests/propose.spec.ts @@ -0,0 +1,264 @@ +/** + * Propose E2E Tests + * Covers: create, list, view, edit, accept, reject, reopen flows + */ +import { test, expect } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL || process.env.FRONTEND_URL || 'http://frontend:3000'; +const ADMIN_USERNAME = 'admin'; +const ADMIN_PASSWORD = 'admin123'; + +test.describe('Propose Management', () => { + const login = async (page: any) => { + console.log('🔐 Logging in...'); + const MAX_LOGIN_RETRIES = 3; + for (let attempt = 1; attempt <= MAX_LOGIN_RETRIES; attempt++) { + console.log(`Login attempt ${attempt}/${MAX_LOGIN_RETRIES}`); + await page.goto(`${BASE_URL}/login`); + await page.waitForLoadState('networkidle'); + + await page.fill('input[type="text"], input[name="username"]', ADMIN_USERNAME); + await page.fill('input[type="password"], input[name="password"]', ADMIN_PASSWORD); + + const loginPromise = page.waitForResponse( + (r: any) => r.url().includes('/auth/token') && r.request().method() === 'POST', + { timeout: 15000 } + ).catch(() => null); + + await page.click('button[type="submit"], button:has-text("Sign in")'); + + const loginResp = await loginPromise; + if (loginResp && loginResp.status() === 200) { + await page.waitForURL(`${BASE_URL}/**`, { timeout: 10000 }).catch(() => {}); + await page.waitForLoadState('networkidle'); + break; + } + if (attempt < MAX_LOGIN_RETRIES) { + console.log('Retrying login in 3s...'); + await page.waitForTimeout(3000); + } + } + + const token = await page.evaluate(() => localStorage.getItem('token')); + expect(token, 'Login failed').toBeTruthy(); + console.log('✅ Logged in'); + }; + + const createProject = async (page: any, name: string) => { + console.log(`📁 Creating project: ${name}`); + await page.goto(`${BASE_URL}/projects`); + await page.waitForLoadState('networkidle'); + await page.click('button:has-text("+ New")'); + await page.waitForSelector('.modal', { timeout: 10000 }); + await page.fill('.modal input[placeholder*="Project name"]', name); + await page.fill('.modal textarea', 'Test project description'); + await page.click('.modal button:has-text("Create")'); + await page.waitForTimeout(1000); + console.log('✅ Project created'); + }; + + const createMilestone = async (page: any, name: string) => { + console.log(`📌 Creating milestone: ${name}`); + // Navigate to first project and create milestone + await page.goto(`${BASE_URL}/projects`); + await page.waitForLoadState('networkidle'); + await page.click('.project-card:first-child'); + await page.waitForURL(/\/projects\/\d+/, { timeout: 10000 }); + await page.click('button:has-text("Milestones")'); + await page.click('button:has-text("+ New")'); + await page.waitForSelector('.modal', { timeout: 10000 }); + await page.fill('.modal input[placeholder*="Milestone title"]', name); + await page.click('.modal button:has-text("Create")'); + await page.waitForTimeout(1000); + console.log('✅ Milestone created'); + }; + + test.describe('Propose CRUD Flow', () => { + test('create propose -> view -> edit -> list', async ({ page }) => { + const TS = Date.now(); + await login(page); + const projectName = `Propose Test Project ${TS}`; + await createProject(page, projectName); + + // Navigate to Proposes page + await page.goto(`${BASE_URL}/proposes`); + await page.waitForLoadState('networkidle'); + + // Select project filter + await page.selectOption('select', { label: projectName }); + await page.waitForTimeout(500); + + // Create propose + const proposeTitle = `Test Propose ${TS}`; + const proposeDesc = `Description for propose ${TS}`; + + await page.click('button:has-text("+ New Propose")'); + await page.waitForSelector('.modal', { timeout: 10000 }); + + await page.fill('.modal input[placeholder*="Propose title"]', proposeTitle); + await page.fill('.modal textarea', proposeDesc); + await page.click('.modal button:has-text("Create")'); + + // Wait for list to update + await page.waitForTimeout(1000); + + // Verify in list + await expect(page.locator(`.milestone-card:has-text("${proposeTitle}")`)).toBeVisible({ timeout: 10000 }); + + // View propose detail + await page.click(`.milestone-card:has-text("${proposeTitle}")`); + await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 }); + + // Verify detail page + await expect(page.locator('h2')).toContainText(proposeTitle); + await expect(page.locator('.project-desc')).toContainText(proposeDesc); + await expect(page.locator('.badge:has-text("open")')).toBeVisible(); + + // Edit propose + await page.click('button:has-text("Edit")'); + await page.waitForSelector('.modal', { timeout: 10000 }); + + const newTitle = `${proposeTitle} (edited)`; + await page.fill('.modal input', newTitle); + await page.click('.modal button:has-text("Save")'); + await page.waitForTimeout(500); + + // Verify edit + await expect(page.locator('h2')).toContainText(newTitle); + + console.log('✅ Propose CRUD test passed'); + }); + }); + + test.describe('Propose Lifecycle Flow', () => { + test('accept propose -> creates feature story task', async ({ page }) => { + const TS = Date.now(); + await login(page); + const projectName = `Accept Propose Project ${TS}`; + await createProject(page, projectName); + + // Create milestone for accept + await createMilestone(page, `Milestone for Accept ${TS}`); + + // Create propose + await page.goto(`${BASE_URL}/proposes`); + await page.selectOption('select', { label: projectName }); + await page.waitForTimeout(500); + + const proposeTitle = `Accept Test Propose ${TS}`; + await page.click('button:has-text("+ New Propose")'); + await page.fill('.modal input', proposeTitle); + await page.fill('.modal textarea', 'To be accepted'); + await page.click('.modal button:has-text("Create")'); + await page.waitForTimeout(1000); + + // Open propose detail + await page.click(`.milestone-card:has-text("${proposeTitle}")`); + await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 }); + + // Accept propose + await page.click('button:has-text("Accept")'); + await page.waitForSelector('.modal:has-text("Accept Propose")', { timeout: 10000 }); + + // Select milestone + await page.selectOption('.modal select', { label: `Milestone for Accept ${TS}` }); + await page.click('.modal button:has-text("Confirm Accept")'); + await page.waitForTimeout(1000); + + // Verify status changed + await expect(page.locator('.badge:has-text("accepted")')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.project-meta:has-text("Task:")')).toBeVisible(); + + console.log('✅ Propose accept test passed'); + }); + + test('reject propose -> can reopen', async ({ page }) => { + const TS = Date.now(); + await login(page); + const projectName = `Reject Propose Project ${TS}`; + await createProject(page, projectName); + + // Create propose + await page.goto(`${BASE_URL}/proposes`); + await page.selectOption('select', { label: projectName }); + await page.waitForTimeout(500); + + const proposeTitle = `Reject Test Propose ${TS}`; + await page.click('button:has-text("+ New Propose")'); + await page.fill('.modal input', proposeTitle); + await page.click('.modal button:has-text("Create")'); + await page.waitForTimeout(1000); + + // Open propose detail + await page.click(`.milestone-card:has-text("${proposeTitle}")`); + await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 }); + + // Reject propose + await page.click('button:has-text("Reject")'); + await page.waitForSelector('.modal:has-text("Reject Propose")', { timeout: 10000 }); + await page.click('.modal button:has-text("Confirm Reject")'); + await page.waitForTimeout(1000); + + // Verify status changed + await expect(page.locator('.badge:has-text("rejected")')).toBeVisible({ timeout: 10000 }); + + // Reopen propose + await page.click('button:has-text("Reopen")'); + await page.waitForSelector('.modal:has-text("Reopen Propose")', { timeout: 10000 }); + await page.click('.modal button:has-text("Confirm Reopen")'); + await page.waitForTimeout(1000); + + // Verify back to open + await expect(page.locator('.badge:has-text("open")')).toBeVisible({ timeout: 10000 }); + + console.log('✅ Propose reject/reopen test passed'); + }); + + test('edit button hidden for accepted propose', async ({ page }) => { + const TS = Date.now(); + await login(page); + const projectName = `Edit Hidden Project ${TS}`; + await createProject(page, projectName); + await createMilestone(page, `Milestone ${TS}`); + + // Create and accept propose + await page.goto(`${BASE_URL}/proposes`); + await page.selectOption('select', { label: projectName }); + await page.waitForTimeout(500); + + await page.click('button:has-text("+ New Propose")'); + await page.fill('.modal input', `Accept Test ${TS}`); + await page.click('.modal button:has-text("Create")'); + await page.waitForTimeout(1000); + + await page.click(`.milestone-card:has-text("Accept Test")`); + await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 }); + + // Accept + await page.click('button:has-text("Accept")'); + await page.selectOption('.modal select', { index: 0 }); + await page.click('.modal button:has-text("Confirm Accept")'); + await page.waitForTimeout(1000); + + // Verify Edit button is hidden + await expect(page.locator('button:has-text("Edit")')).not.toBeVisible(); + await expect(page.locator('.badge:has-text("accepted")')).toBeVisible(); + + console.log('✅ Edit button hidden test passed'); + }); + }); + + test.describe('Propose Permissions', () => { + test('cannot create propose without project selection', async ({ page }) => { + await login(page); + await page.goto(`${BASE_URL}/proposes`); + await page.waitForLoadState('networkidle'); + + // Button should be disabled + const button = page.locator('button:has-text("+ New Propose")'); + await expect(button).toBeDisabled(); + + console.log('✅ Propose create disabled test passed'); + }); + }); +});