feat(sdlc): staging environment + automated staging→prod pipeline
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 10m0s
PR Checks / Backend Smoke (pull_request) Successful in 48s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 24s

- pr-checks.yml: PR gate — frontend typecheck+jest, backend import smoke,
  image builds (no push), Claude AI review posted as PR comment (severe
  findings block merge)
- deploy.yml (replaces build-and-push.yml): merge to main builds+pushes
  images tagged sha-<sha>/staging, deploys the staging Portainer stack via
  webhook, runs Playwright E2E journeys against staging, then retags the
  verified images :prod (previous kept as :prod-previous) and deploys prod
- docker-compose.portainer.staging.yml: second Portainer stack — :staging
  images, sc_staging_* names, own macvlan IPs, Airflow on 8081; data
  bootstrapped from source via the staging Airflow DAGs
- prod compose now pins :prod instead of :latest (only the promotion step
  moves it; :latest is no longer published)
- e2e/: 6 Playwright journeys (search, postcode, detail, compare, rankings)
  driven by BASE_URL — the promotion gate
- scripts/ci/ai_review.py: Claude review with structured JSON findings
- docs/DEPLOY.md: full SDLC doc incl. one-time setup checklist and rollback
- replaced removed 'next lint' with tsc typecheck; fixed stale jest tests
  (slug URLs, N/A formatting, stable trend, fake-timer setup)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
This commit is contained in:
Tudor
2026-07-03 06:50:40 +01:00
co-authored by Claude Fable 5
parent f2ed49c0a1
commit 4a52735356
15 changed files with 990 additions and 46 deletions
@@ -34,24 +34,24 @@ describe('SchoolCard', () => {
render(<SchoolCard school={mockSchool} />);
const link = screen.getByRole('link', { name: /test primary school/i });
expect(link).toHaveAttribute('href', '/school/100001');
expect(link).toHaveAttribute('href', '/school/100001-test-primary-school');
});
it('calls onAddToCompare when Add to Compare button is clicked', () => {
it('calls onAddToCompare when the Compare button is clicked', () => {
const mockAddToCompare = jest.fn();
render(<SchoolCard school={mockSchool} onAddToCompare={mockAddToCompare} />);
const addButton = screen.getByText('Add to Compare');
const addButton = screen.getByText('+ Compare');
fireEvent.click(addButton);
expect(mockAddToCompare).toHaveBeenCalledWith(mockSchool);
expect(mockAddToCompare).toHaveBeenCalledTimes(1);
});
it('does not render Add to Compare button when handler not provided', () => {
it('does not render the Compare button when handler not provided', () => {
render(<SchoolCard school={mockSchool} />);
expect(screen.queryByText('Add to Compare')).not.toBeInTheDocument();
expect(screen.queryByText('+ Compare')).not.toBeInTheDocument();
});
it('displays trend indicator for positive change', () => {
+13 -9
View File
@@ -19,7 +19,7 @@ describe('formatPercentage', () => {
});
it('handles null values', () => {
expect(formatPercentage(null)).toBe('-');
expect(formatPercentage(null)).toBe('N/A');
});
});
@@ -31,7 +31,7 @@ describe('formatProgress', () => {
});
it('handles null values', () => {
expect(formatProgress(null)).toBe('-');
expect(formatProgress(null)).toBe('N/A');
});
});
@@ -44,16 +44,16 @@ describe('calculateTrend', () => {
expect(calculateTrend(70, 75)).toBe('down');
});
it('calculates same trend', () => {
expect(calculateTrend(75, 75)).toBe('same');
it('calculates stable trend', () => {
expect(calculateTrend(75, 75)).toBe('stable');
});
it('handles null previous value', () => {
expect(calculateTrend(75, null)).toBe('same');
expect(calculateTrend(75, null)).toBe('stable');
});
it('handles null current value', () => {
expect(calculateTrend(null, 75)).toBe('same');
expect(calculateTrend(null, 75)).toBe('stable');
});
});
@@ -72,7 +72,13 @@ describe('isValidPostcode', () => {
});
describe('debounce', () => {
jest.useFakeTimers();
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('delays function execution', () => {
const mockFn = jest.fn();
@@ -100,8 +106,6 @@ describe('debounce', () => {
expect(mockFn).toHaveBeenCalledWith('third');
expect(mockFn).toHaveBeenCalledTimes(1);
});
jest.useRealTimers();
});
describe('buildOfstedListBadge', () => {
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"