diff --git a/frontend/website/cypress/e2e/confirm-email.cy.ts b/frontend/website/cypress/e2e/confirm-email.cy.ts index deabca6..2ef0274 100644 --- a/frontend/website/cypress/e2e/confirm-email.cy.ts +++ b/frontend/website/cypress/e2e/confirm-email.cy.ts @@ -13,6 +13,10 @@ describe('email confirmation', () => { statusCode: 200, body: { sets: [] }, }) + cy.intercept('GET', '**/api/schedules', { + statusCode: 200, + body: { schedules: [] }, + }) }) it('chooses a password, confirms the account, and opens the dashboard', () => { diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts index 75c441c..00d0d19 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -31,6 +31,14 @@ describe('guest authentication pages', () => { cy.location('search').should('include', 'redirect=/sets/41') }) + it('redirects guests away from protected schedule routes', () => { + cy.visit('/sets/41/schedules/new') + cy.location('pathname').should('equal', '/login') + + cy.visit('/schedules/73') + cy.location('pathname').should('equal', '/login') + }) + it('shows the login form and links to signup', () => { cy.visit('/login') diff --git a/frontend/website/cypress/e2e/session-auth.cy.ts b/frontend/website/cypress/e2e/session-auth.cy.ts index defb942..eb88490 100644 --- a/frontend/website/cypress/e2e/session-auth.cy.ts +++ b/frontend/website/cypress/e2e/session-auth.cy.ts @@ -43,6 +43,10 @@ describe('session authentication', () => { statusCode: 200, body: { sets: [] }, }) + cy.intercept('GET', '**/api/schedules', { + statusCode: 200, + body: { schedules: [] }, + }) }) it('restores an authenticated session on a protected route', () => { diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts new file mode 100644 index 0000000..7967845 --- /dev/null +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -0,0 +1,242 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +const bibleLayout = { + set: { id: 41, name: 'Bible' }, + elements: [ + { + id: 1, + name: 'Genesis', + kind: 'book', + children: [ + { + id: 3, + name: 'Creation', + kind: 'portion', + children: [ + { + id: 4, + name: 'Chapter 1', + kind: 'chapter', + children: [], + }, + ], + }, + ], + }, + { + id: 2, + name: 'Exodus', + kind: 'book', + children: [ + { + id: 5, + name: 'Chapter 1', + kind: 'chapter', + children: [], + }, + ], + }, + ], +} + +const scheduleDetail = { + schedule: { + id: 73, + set: { id: 41, name: 'Bible' }, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + assignmentCount: 2, + days: [ + { + date: '2026-08-10', + assignments: [ + { + element: { + id: 4, + name: 'Chapter 1', + kind: 'chapter', + path: ['Genesis', 'Creation', 'Chapter 1'], + }, + }, + ], + }, + { date: '2026-08-11', assignments: [] }, + { + date: '2026-08-12', + assignments: [ + { + element: { + id: 5, + name: 'Chapter 1', + kind: 'chapter', + path: ['Exodus', 'Chapter 1'], + }, + }, + ], + }, + ], + }, +} + +function interceptAuthenticatedUser(): void { + cy.intercept('GET', '**/api/me', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('me') +} + +describe('set scheduling', () => { + beforeEach(() => { + interceptAuthenticatedUser() + }) + + it('creates a schedule from a set and shows every day', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: bibleLayout, + }).as('layout') + cy.intercept('POST', '**/api/schedules', (request) => { + expect(request.headers.accept).to.equal('application/json') + expect(request.body).to.deep.equal({ + setId: 41, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + }) + request.reply({ statusCode: 201, body: scheduleDetail }) + }).as('createSchedule') + + cy.visit('/sets/41') + cy.wait('@me') + cy.wait('@layout') + cy.contains('a', 'Schedule this set') + .should('have.attr', 'href', '/sets/41/schedules/new') + .click() + + cy.location('pathname').should('equal', '/sets/41/schedules/new') + cy.get('h1').should('have.text', 'Schedule Bible') + cy.get('#schedule-level option').then(($options) => { + expect([...$options].map((option) => option.textContent)).to.deep.equal([ + 'Choose a level', + 'Book (2)', + 'Portion (1)', + 'Chapter (2)', + ]) + }) + cy.get('#schedule-level').select('chapter') + cy.get('#schedule-start-date').type('2026-08-10') + cy.get('#schedule-target-date').type('2026-08-12') + cy.get('form').submit() + cy.wait('@createSchedule') + + cy.location('pathname').should('equal', '/schedules/73') + cy.get('h1').should('have.text', 'Bible schedule') + cy.get('[data-schedule-day]').should('have.length', 3) + cy.get('[data-schedule-date="2026-08-10"]') + .should('contain.text', 'Genesis / Creation / Chapter 1') + .and('contain.text', 'Chapter') + cy.get('[data-schedule-date="2026-08-11"]') + .should('contain.text', 'Rest day') + .and('not.contain.text', 'Chapter 1') + cy.get('[data-schedule-date="2026-08-12"]').should( + 'contain.text', + 'Exodus / Chapter 1', + ) + }) + + it('validates the schedule form before submitting', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: bibleLayout, + }).as('layout') + cy.intercept('POST', '**/api/schedules').as('createSchedule') + + cy.visit('/sets/41/schedules/new') + cy.wait('@me') + cy.wait('@layout') + cy.get('form').submit() + + cy.get('#schedule-level-error') + .should('have.text', 'Choose a level to schedule.') + .and('be.visible') + cy.get('#schedule-start-date-error') + .should('have.text', 'Choose a start date.') + .and('be.visible') + cy.get('#schedule-target-date-error') + .should('have.text', 'Choose a target date.') + .and('be.visible') + cy.get('@createSchedule.all').should('have.length', 0) + + cy.get('#schedule-level').select('chapter') + cy.get('#schedule-start-date').type('2026-08-12') + cy.get('#schedule-target-date').type('2026-08-10') + cy.get('form').submit() + cy.get('#schedule-target-date-error').should( + 'have.text', + 'Target date cannot be before the start date.', + ) + cy.get('@createSchedule.all').should('have.length', 0) + }) + + it('loads a persisted schedule directly and handles missing schedules', () => { + cy.intercept('GET', '**/api/schedules/73', { + statusCode: 200, + body: scheduleDetail, + }).as('schedule') + + cy.visit('/schedules/73') + cy.wait('@me') + cy.wait('@schedule') + cy.get('h1').should('have.text', 'Bible schedule') + cy.contains('2 chapters across 3 days').should('be.visible') + + cy.intercept('GET', '**/api/schedules/74', { + statusCode: 404, + body: { error: 'schedule not found' }, + }).as('missingSchedule') + cy.visit('/schedules/74') + cy.wait('@missingSchedule') + cy.get('h1').should('have.text', 'Schedule not found') + }) + + it('lists the users schedules on the dashboard', () => { + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [] }, + }).as('sets') + cy.intercept('GET', '**/api/schedules', (request) => { + expect(request.headers.accept).to.equal('application/json') + request.reply({ + statusCode: 200, + body: { + schedules: [ + { + id: 73, + set: { id: 41, name: 'Bible' }, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + assignmentCount: 2, + }, + ], + }, + }) + }).as('schedules') + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@sets') + cy.wait('@schedules') + + cy.get('#schedules-heading').should('have.text', 'Your schedules') + cy.get('ul[aria-label="Your schedules"]') + .should('contain.text', 'Bible') + .and('contain.text', 'Chapter') + .and('contain.text', '2 assignments') + cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73') + }) +}) diff --git a/frontend/website/cypress/e2e/sets-dashboard.cy.ts b/frontend/website/cypress/e2e/sets-dashboard.cy.ts index 4b6fc2d..6cff9ed 100644 --- a/frontend/website/cypress/e2e/sets-dashboard.cy.ts +++ b/frontend/website/cypress/e2e/sets-dashboard.cy.ts @@ -13,6 +13,10 @@ function interceptAuthenticatedUser(): void { describe('sets dashboard', () => { beforeEach(() => { interceptAuthenticatedUser() + cy.intercept('GET', '**/api/schedules', { + statusCode: 200, + body: { schedules: [] }, + }) }) it('shows every available set as a detail link', () => {