Skip to content

Commit 393aa06

Browse files
authored
Merge pull request #102 from Akash29g/feat/e2e-playwright
feat(e2e): Playwright E2E suite + CI job
2 parents 6e5166d + 9c8690d commit 393aa06

14 files changed

Lines changed: 467 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,88 @@ jobs:
7171
- name: Test (Vitest, single run + coverage thresholds)
7272
run: npx ng test --watch=false
7373

74+
75+
# ──────────────────────── E2E (Playwright) ────────────────────────
76+
e2e:
77+
name: e2e (Playwright)
78+
runs-on: ubuntu-latest
79+
services:
80+
postgres:
81+
image: postgres:16
82+
env:
83+
POSTGRES_USER: postgres
84+
POSTGRES_PASSWORD: postgres
85+
POSTGRES_DB: docanalytics
86+
ports:
87+
- 5432:5432
88+
options: >-
89+
--health-cmd "pg_isready -U postgres"
90+
--health-interval 10s
91+
--health-timeout 5s
92+
--health-retries 5
93+
steps:
94+
- name: Checkout
95+
uses: actions/checkout@v4
96+
97+
- name: Setup .NET 10
98+
uses: actions/setup-dotnet@v4
99+
with:
100+
dotnet-version: '10.0.x'
101+
102+
- name: Setup Node 22
103+
uses: actions/setup-node@v4
104+
with:
105+
node-version: '22'
106+
cache: npm
107+
cache-dependency-path: docanalytics-web/package-lock.json
108+
109+
- name: Trust dev HTTPS cert (API serves https on :7001)
110+
run: dotnet dev-certs https
111+
112+
- name: Restore + build API (so startup is fast)
113+
run: |
114+
dotnet restore DocAnalytics.slnx
115+
dotnet build DocAnalytics.Api -c Release --no-restore
116+
117+
- name: Install web dependencies
118+
working-directory: docanalytics-web
119+
run: npm ci
120+
121+
- name: Install Playwright browsers
122+
working-directory: docanalytics-web
123+
run: npx playwright install --with-deps
124+
125+
- name: Start API and run Playwright E2E
126+
working-directory: docanalytics-web
127+
env:
128+
ASPNETCORE_ENVIRONMENT: Development
129+
ConnectionStrings__Default: "Host=localhost;Port=5432;Database=docanalytics;Username=postgres;Password=postgres"
130+
Jwt__Key: ${{ secrets.E2E_JWT_KEY }}
131+
CI: 'true'
132+
run: |
133+
set -e
134+
# Start the API in the background WITHIN this step so it stays alive
135+
# while Playwright runs (background procs are killed at step end).
136+
( cd .. && dotnet run --project DocAnalytics.Api -c Release --no-build > api.log 2>&1 & )
137+
echo "Waiting for API on https://localhost:7001 ..."
138+
up=""
139+
for i in $(seq 1 90); do
140+
if curl -sk https://localhost:7001/health >/dev/null 2>&1; then up=1; echo "API is up"; break; fi
141+
sleep 2
142+
done
143+
if [ -z "$up" ]; then echo "API did not start"; cat ../api.log || true; exit 1; fi
144+
npx playwright test
145+
146+
- name: Upload Playwright report
147+
if: ${{ !cancelled() }}
148+
uses: actions/upload-artifact@v4
149+
with:
150+
name: playwright-report
151+
path: docanalytics-web/playwright-report
152+
retention-days: 7
153+
154+
155+
74156
# ──────────────────────── DevSecOps (scans) ────────────────────────
75157
devsecops:
76158
name: devsecops (secrets + deps + image scan)

‎DocAnalytics.Data/Seeding/DbSeeder.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,15 @@ public static async Task SeedAsync(AppDbContext db)
208208

209209

210210
};
211+
// Exclude any error codes already inserted by the backfill above,
212+
// otherwise seeding a brand-new DB hits a duplicate-key crash on error_code.
213+
var existingCodes = await db.ErrorCatalog.Select(x => x.ErrorCode).ToListAsync();
211214
var errorCatalog = errorDefs
215+
.Where(e => !existingCodes.Contains(e.Code))
212216
.Select(e => new ErrorCatalog { Id = Guid.NewGuid(), ErrorCode = e.Code, Description = e.Desc, RemediationMsg = e.Remediation, CreatedAt = now, UpdatedAt = now })
213217
.ToArray();
214218

219+
215220
string[] sources = { "S3_Bucket_Alpha", "SFTP_Beta", "API_Upload", "Legacy_Import", "Azure_Blob_Gamma" };
216221
string[] pipeline = { "Upload", "Validate", "Transform", "Load" };
217222

‎docanalytics-web/.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,9 @@ __screenshots__/
4242
# System files
4343
.DS_Store
4444
Thumbs.db
45+
46+
# Playwright
47+
/e2e/.auth/
48+
/playwright-report/
49+
/test-results/
50+
/playwright/.cache/

‎docanalytics-web/e2e/auth.setup.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test as setup, expect } from '@playwright/test';
2+
import path from 'node:path';
3+
4+
// Where the reusable authenticated state is saved (matches playwright.config.ts).
5+
const authFile = path.join(__dirname, '.auth', 'state.json');
6+
7+
// Creds from env in CI; fall back to local dev account.
8+
const EMAIL = process.env.E2E_EMAIL ?? 'admin@acme.com';
9+
const PASSWORD = process.env.E2E_PASSWORD ?? 'Password123!';
10+
11+
setup('authenticate via UI and save storageState', async ({ page }) => {
12+
await page.goto('/login');
13+
await page.fill('input[formControlName="email"]', EMAIL);
14+
await page.fill('input[formControlName="password"]', PASSWORD);
15+
await page.click('button[type="submit"].btn');
16+
17+
// Admin -> routed to first site dashboard
18+
await page.waitForURL('**/site/*/dashboard', { timeout: 15_000 });
19+
20+
// Verify JWT persisted under the agreed key
21+
const token = await page.evaluate(() => localStorage.getItem('da_token'));
22+
expect(token, 'da_token should be set in localStorage after login').toBeTruthy();
23+
24+
// Save cookies + localStorage (captures da_token) for reuse
25+
await page.context().storageState({ path: authFile });
26+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// A7 runs AUTHENTICATED. Verifies auth-site.interceptor adds
4+
// Authorization: Bearer <token> AND X-Site-Id on API calls made from a site page.
5+
6+
test.describe('Auth-site interceptor headers', () => {
7+
test('API calls carry Authorization and X-Site-Id', async ({ page }) => {
8+
await page.goto('/login');
9+
await page.waitForURL(/\/site\/[^/]+\/dashboard/, { timeout: 15_000 });
10+
11+
// Reload and capture a data API call (exclude /auth/* which fires before site is set).
12+
const [req] = await Promise.all([
13+
page.waitForRequest(
14+
(r) => r.url().includes('/api/v1/') && !r.url().includes('/auth/'),
15+
),
16+
page.reload(),
17+
]);
18+
19+
const headers = req.headers();
20+
expect(headers['authorization']).toMatch(/^Bearer .+/);
21+
expect(headers['x-site-id']).toBeTruthy();
22+
});
23+
});

‎docanalytics-web/e2e/login.spec.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// A4 login tests must run LOGGED OUT — override the shared storageState.
4+
test.use({ storageState: { cookies: [], origins: [] } });
5+
6+
const EMAIL = process.env.E2E_EMAIL ?? 'admin@acme.com';
7+
const PASSWORD = process.env.E2E_PASSWORD ?? 'Password123!';
8+
9+
const DEV_EMAIL = process.env.E2E_DEV_EMAIL ?? 'developer@platform.com';
10+
const DEV_PASSWORD = process.env.E2E_DEV_PASSWORD ?? 'Password123!';
11+
12+
test.describe('Login', () => {
13+
test('valid admin login routes to a site dashboard', async ({ page }) => {
14+
await page.goto('/login');
15+
await page.fill('input[formControlName="email"]', EMAIL);
16+
await page.fill('input[formControlName="password"]', PASSWORD);
17+
await page.click('button[type="submit"].btn');
18+
19+
await expect(page).toHaveURL(/\/site\/[^/]+\/dashboard/, { timeout: 15_000 });
20+
21+
const token = await page.evaluate(() => localStorage.getItem('da_token'));
22+
expect(token).toBeTruthy();
23+
});
24+
25+
test('valid Developer login routes to /provision', async ({ page }) => {
26+
await page.goto('/login');
27+
await page.fill('input[formControlName="email"]', DEV_EMAIL);
28+
await page.fill('input[formControlName="password"]', DEV_PASSWORD);
29+
await page.click('button[type="submit"].btn');
30+
31+
await expect(page).toHaveURL(/\/provision$/, { timeout: 15_000 });
32+
});
33+
34+
test('invalid credentials show inline error and stay on /login', async ({ page }) => {
35+
await page.goto('/login');
36+
await page.fill('input[formControlName="email"]', EMAIL);
37+
await page.fill('input[formControlName="password"]', 'WrongPassword!');
38+
await page.click('button[type="submit"].btn');
39+
40+
await expect(page.locator('.alert[role="alert"]')).toHaveText('Invalid email or password.');
41+
await expect(page).toHaveURL(/\/login$/);
42+
43+
const token = await page.evaluate(() => localStorage.getItem('da_token'));
44+
expect(token).toBeFalsy();
45+
});
46+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// A5 runs AUTHENTICATED (uses the shared storageState with da_token).
4+
// We intentionally do NOT clear storageState here.
5+
6+
test.describe('Session rehydration', () => {
7+
test('reload with token but empty signals recovers via /auth/me', async ({ page }) => {
8+
// With a live token, hitting /login auto-routes us to the dashboard.
9+
await page.goto('/login');
10+
await page.waitForURL(/\/site\/[^/]+\/dashboard/, { timeout: 15_000 });
11+
const dashUrl = page.url();
12+
13+
// Sanity: token is present in localStorage.
14+
const token = await page.evaluate(() => localStorage.getItem('da_token'));
15+
expect(token).toBeTruthy();
16+
17+
// Reload wipes in-memory signals; the guard must call /auth/me to rehydrate.
18+
const [meResponse] = await Promise.all([
19+
page.waitForResponse(
20+
(r) => r.url().includes('/auth/me') && r.request().method() === 'GET',
21+
),
22+
page.reload(),
23+
]);
24+
expect(meResponse.ok()).toBeTruthy();
25+
26+
// Session recovered -> we STAY on the dashboard, not bounced to /login.
27+
await expect(page).toHaveURL(dashUrl);
28+
});
29+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// A6 runs AUTHENTICATED (shared storageState = admin@acme.com, Acme sites only).
4+
// admin@acme has NO access to Globex sites, so visiting one must redirect
5+
// to the first allowed (Acme) site.
6+
7+
test.describe('Site-access guard', () => {
8+
test('visiting a site without access redirects to the first allowed site', async ({ page }) => {
9+
// A real Globex site the Acme admin cannot access (from the DB seed).
10+
const noAccessSite = 'b1111111-1111-1111-1111-111111111111';
11+
12+
await page.goto(`/site/${noAccessSite}/dashboard`);
13+
14+
// The guard's /auth/me + redirect is async — WAIT until we've been moved
15+
// OFF the no-access site to a real dashboard (not the fake one, not /login).
16+
await page.waitForURL(
17+
(url) =>
18+
/\/site\/[^/]+\/dashboard$/.test(url.pathname) &&
19+
!url.pathname.includes(noAccessSite),
20+
{ timeout: 15_000 },
21+
);
22+
23+
expect(page.url()).not.toContain(noAccessSite);
24+
expect(page.url()).not.toContain('/login');
25+
});
26+
});

‎docanalytics-web/e2e/smoke.spec.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
3+
// A8 smoke tests run AUTHENTICATED (shared storageState).
4+
5+
async function gotoDashboard(page: Page): Promise<string> {
6+
await page.goto('/login');
7+
await page.waitForURL(/\/site\/[^/]+\/dashboard/, { timeout: 15_000 });
8+
return page.url().match(/\/site\/([^/]+)\//)![1];
9+
}
10+
11+
test.describe('Smoke', () => {
12+
test('dashboard renders stat cards and charts', async ({ page }) => {
13+
await gotoDashboard(page);
14+
await expect(page.locator('app-stat-card').first()).toBeVisible({ timeout: 10_000 });
15+
await expect(page.locator('app-chart-card').first()).toBeVisible({ timeout: 10_000 });
16+
});
17+
18+
test('batches page renders the data table', async ({ page }) => {
19+
const siteId = await gotoDashboard(page);
20+
await page.goto(`/site/${siteId}/batches`);
21+
await expect(page.locator('app-data-table')).toBeVisible({ timeout: 10_000 });
22+
});
23+
24+
test('batches list pagination works', async ({ page }) => {
25+
const siteId = await gotoDashboard(page);
26+
await page.goto(`/site/${siteId}/batches`);
27+
28+
const footer = page.locator('.dt-footer');
29+
await expect(footer).toBeVisible({ timeout: 10_000 });
30+
31+
const pageInfo = page.locator('.page-info');
32+
await expect(pageInfo).toContainText('Page 1');
33+
34+
// Only exercise navigation if there's more than one page of data.
35+
const nextBtn = page.getByRole('button', { name: 'Next' });
36+
if (await nextBtn.isEnabled()) {
37+
await nextBtn.click();
38+
await expect(pageInfo).toContainText('Page 2');
39+
await page.getByRole('button', { name: 'Prev' }).click();
40+
await expect(pageInfo).toContainText('Page 1');
41+
}
42+
});
43+
44+
test('errors CSV export triggers a download', async ({ page }) => {
45+
const siteId = await gotoDashboard(page);
46+
await page.goto(`/site/${siteId}/errors`);
47+
48+
const downloadPromise = page.waitForEvent('download');
49+
await page.click('button.export-btn');
50+
const download = await downloadPromise;
51+
52+
expect(download.suggestedFilename()).toContain('.csv');
53+
});
54+
});

‎docanalytics-web/e2e/tsconfig.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"types": [ "node", "@playwright/test" ],
5+
"noPropertyAccessFromIndexSignature": false,
6+
"outDir": "../out-tsc/e2e"
7+
},
8+
"include": [ "**/*.ts", "../playwright.config.ts" ]
9+
}

0 commit comments

Comments
 (0)