Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7ab80e6
feat: use one source of login state, add a login page and delete a gy…
Oct 30, 2026
5b08108
feat: add npm run db:demo for a demo coach with example gymnasts
Oct 30, 2026
ffc38ad
docs: explain the test login, the demo coach and what a real login wo…
Oct 30, 2026
6befb21
build(e2e): add playwright and axe-core with an e2e script
Oct 30, 2026
9326b7a
test(e2e): configure a local app or any address under test
Oct 30, 2026
ac67276
test(e2e): add the search, filter and sort flow and vault bands
Oct 30, 2026
b3242a1
test(e2e): add atypical use, accessibility and api tests
Oct 30, 2026
d12907f
docs(e2e): describe how to run the end-to-end tests
Oct 30, 2026
dc5d91e
feat(gymnasts): redesign the page, the add form and the delete dialog
Oct 30, 2026
e58edfd
chore(e2e): merge dev into the end-to-end branch
Oct 30, 2026
ac353f0
Merge pull request #142 from IT2810-H26/139-end-to-end-tests
anettkva Oct 30, 2026
7383d63
fix(gymnasts): green save, red error, card hover, dark keep button
Oct 30, 2026
f875847
chore: merge dev into 136-accounts-login-and-demo-coach
Oct 30, 2026
51d9f45
fix: show the vault group number instead of the description on the el…
Oct 30, 2026
147fc48
fix(gymnasts): let the save and keep buttons use their own colors in …
Oct 30, 2026
639742b
fix(a11y): give the login page and the login check a page title
Oct 30, 2026
3618289
docs: say that the demo coach is shared and should be reset before a …
Oct 30, 2026
e617e48
style(seed): format demo.ts with Prettier
Oct 30, 2026
d792a2d
style(gymnasts): use a mint green for the save button in dark mode
Oct 30, 2026
2b3f8df
style(gymnasts): make the edge of "No, keep it" a little darker in da…
Oct 30, 2026
1cb18e9
style(gymnasts): make the edge of "No, keep it" darker in dark mode (…
Oct 30, 2026
d427541
style(gymnasts): set the edge of "No, keep it" to 6:1 on the card in …
Oct 30, 2026
4727ed6
test(gymnasts): keep only the WCAG limit for the edge of "No, keep it"
Oct 30, 2026
c8b7630
style(gymnasts): make the edge of "No, keep it" a blue-grey in dark mode
Oct 30, 2026
b889878
Merge pull request #144 from IT2810-H26/125-show-the-vault-group-numb…
ronjalh Oct 30, 2026
59141a5
Merge pull request #141 from IT2810-H26/136-accounts-login-and-demo-c…
anettkva Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ scripts/extract-cop/en_1.1.-WAG-Code-of-Points-2025-2028.pdf
scripts/extract-cop/review.html
scripts/extract-cop/.venv
__pycache__

# Playwright (end-to-end tests)
playwright-report/
test-results/
100 changes: 79 additions & 21 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
"demo": "tsx src/seed/demo.ts",
"synthetic": "tsx src/seed/synthetic.ts",
"prisma:studio": "prisma studio"
},
Expand Down
129 changes: 129 additions & 0 deletions backend/src/seed/demo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Tests for the demo coach (see "The demo coach" in docs/AUTHENTICATION.md).
// They run against the local database from backend/.env, which must have the
// catalog (`npm run db:seed`), and they use an address of their own so the real
// demo coach is not touched. Without DATABASE_URL they are skipped.

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { DEMO_GYMNASTS } from '../../../shared/demo.js'
import { createPrismaClient } from '../db.js'
import type { PrismaClient } from '../generated/prisma/client.js'
import { createApolloServer } from '../server/app.js'
import { createContext } from '../server/context.js'
import { resetDemoCoach } from './demo.js'

const hasDatabase = Boolean(process.env['DATABASE_URL'])
const EMAIL = 'test-138-demo@example.com'
const OTHER = 'test-138-other@example.com'

describe.runIf(hasDatabase)('resetDemoCoach', () => {
let prisma: PrismaClient

const clean = () =>
prisma.coach.deleteMany({ where: { email: { startsWith: 'test-138-' } } })
const gymnastsOf = async (email: string) =>
(
await prisma.gymnast.findMany({
where: { coach: { email } },
include: { elements: true },
orderBy: { name: 'asc' },
})
).map((g) => ({ name: g.name, elements: g.elements.length }))

beforeAll(async () => {
prisma = createPrismaClient()
await clean()
})
afterAll(async () => {
await clean()
await prisma.$disconnect()
})

it('makes the coach with the example gymnasts, each with elements from the catalog', async () => {
const result = await resetDemoCoach(prisma, EMAIL)

expect(result).toMatchObject({
gymnasts: DEMO_GYMNASTS.length,
elements: 9,
})
expect(await gymnastsOf(EMAIL)).toEqual(
[...DEMO_GYMNASTS].sort().map((name) => ({ name, elements: 3 })),
)
const coach = await prisma.coach.findFirstOrThrow({
where: { email: EMAIL },
})
expect(coach.googleId).toBe(`dev:${EMAIL}`)
})

it('is the coach that the test login finds, so the login page can log in as it', async () => {
const server = createApolloServer()
await server.start()
try {
const response = await server.executeOperation(
{
query: `mutation ($email: String!) { devLogin(email: $email) { coach { id email } } }`,
variables: { email: EMAIL },
},
{
contextValue: createContext(
prisma,
{
port: 3001,
imageBaseUrl: 'http://images.test/images',
corsOrigins: [],
sessionSecret: 'a-secret-that-is-at-least-32-characters-long',
devLogin: true,
},
{},
),
},
)
if (response.body.kind !== 'single')
throw new Error('expected one result')
const coach = await prisma.coach.findFirstOrThrow({
where: { email: EMAIL },
})
expect(response.body.singleResult.errors).toBeUndefined()
expect(
(
response.body.singleResult.data as {
devLogin: { coach: { id: string } }
}
).devLogin.coach.id,
).toBe(coach.id)
expect(await prisma.coach.count({ where: { email: EMAIL } })).toBe(1)
} finally {
await server.stop()
}
})

it('puts the example data back after visitors have changed it, and touches no other coach', async () => {
const coach = await prisma.coach.findFirstOrThrow({
where: { email: EMAIL },
})
await prisma.gymnast.deleteMany({
where: { coachId: coach.id, name: DEMO_GYMNASTS[0]! },
})
await prisma.gymnast.create({
data: { coachId: coach.id, name: 'Added by a visitor' },
})
const other = await prisma.coach.create({
data: {
googleId: 'dev:' + OTHER,
email: OTHER,
name: 'Other',
gymnasts: { create: [{ name: 'Not the demo' }] },
},
})

await resetDemoCoach(prisma, EMAIL)

expect((await gymnastsOf(EMAIL)).map((g) => g.name)).toEqual(
[...DEMO_GYMNASTS].sort(),
)
expect(await gymnastsOf(OTHER)).toEqual([
{ name: 'Not the demo', elements: 0 },
])
expect(await prisma.coach.count({ where: { id: other.id } })).toBe(1)
expect(await prisma.coach.count({ where: { email: EMAIL } })).toBe(1)
})
})
100 changes: 100 additions & 0 deletions backend/src/seed/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Makes the demo coach and puts its example data back (see "The demo coach" in
// docs/AUTHENTICATION.md).
//
// Run with `npm run db:demo` from the repository root, after `npm run db:seed`
// (the example elements come from the catalog). It can be run again whenever
// the demo coach should start over: everything the demo coach has is deleted
// first, so whatever visitors added or deleted is gone. Other coaches are never
// touched.
//
// The demo coach is a coach of the test login (`devLogin`, see "Login for
// development" in docs/API.md): its Google id is "dev:<e-mail address>", so
// `devLogin` finds it. It has no password; this script only makes the coach
// and its data, and the login itself still needs DEV_LOGIN=true on the server.

import { fileURLToPath } from 'node:url'
import { DEMO_EMAIL, DEMO_GYMNASTS, DEMO_NAME } from '../../../shared/demo.js'
import { createPrismaClient } from '../db.js'
import type { PrismaClient } from '../generated/prisma/client.js'

/** The elements each demo gymnast "can do", from the catalog. */
const ELEMENTS_PER_GYMNAST = 3

export type DemoResult = {
coachId: string
gymnasts: number
elements: number
}

/**
* Makes the demo coach if there is none, and gives it exactly the example
* gymnasts. Each gymnast gets a few elements in the list of what they can do:
* real elements of the catalog, the first ones that are not removed, one after
* the other. If the catalog is empty the gymnasts get no elements. `email` is
* only different in the tests, which must not touch the real demo coach.
*/
export async function resetDemoCoach(
prisma: PrismaClient,
email: string = DEMO_EMAIL,
): Promise<DemoResult> {
const googleId = `dev:${email}`
// Elements of the catalog and not the synthetic test rows (SYN-), which
// `npm run db:synthetic -- remove` deletes.
const elements = await prisma.element.findMany({
where: { removedAt: null, NOT: { id: { startsWith: 'SYN-' } } },
orderBy: { id: 'asc' },
take: DEMO_GYMNASTS.length * ELEMENTS_PER_GYMNAST,
select: { id: true },
})

return prisma.$transaction(async (tx) => {
const coach = await tx.coach.upsert({
where: { googleId },
create: { googleId, email, name: DEMO_NAME },
update: { name: DEMO_NAME },
})
// Deleting the gymnasts deletes their routines and lists (ON DELETE CASCADE).
await tx.gymnast.deleteMany({ where: { coachId: coach.id } })

let added = 0
for (const [index, name] of DEMO_GYMNASTS.entries()) {
const own = elements.slice(
index * ELEMENTS_PER_GYMNAST,
(index + 1) * ELEMENTS_PER_GYMNAST,
)
await tx.gymnast.create({
data: {
coachId: coach.id,
name,
elements: { create: own.map((e) => ({ elementId: e.id })) },
},
})
added += own.length
}
return {
coachId: coach.id,
gymnasts: DEMO_GYMNASTS.length,
elements: added,
}
})
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
const prisma = createPrismaClient()
resetDemoCoach(prisma)
.then((result) => {
console.log(
`The demo coach (${DEMO_EMAIL}) has ${result.gymnasts} gymnasts with ${result.elements} elements.`,
)
if (result.elements === 0) {
console.log(
'The catalog is empty, so the gymnasts have no elements. Run "npm run db:seed" first.',
)
}
})
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())
}
19 changes: 19 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,12 @@
- **What the team did:** Decided that the page says "1000+" when there are more than 1000 results, and that the server must not count more than that.
- **What Claude did:** Made the database stop counting at 1001 (`count` with `take`), kept the schema the same (a `totalCount` above 1000 means more than 1000), wrote the tests (the count stops at one more than the most, it is not capped below that, and a search is capped too) and broke the cap to check that they fail without it.

## The login, the demo coach and deleting a gymnast (`src/components/auth/`, `src/components/ProtectedRoute.tsx`, `backend/src/seed/demo.ts`, `docs/AUTHENTICATION.md`)

- **Tool:** Claude Code
- **What the team did:** Asked for one source of login state after peer feedback (the menu and the gymnasts page disagreed), and for a clear way from the login to saving and seeing your own data. Decided to keep the test login and a seeded demo coach, and not to build passwords, when it was clear that the course server has only http (so a password would be sent as plain text) and that the assignment asks for saved user data and not for a login. Asked Claude to delete the work on password accounts, and to write down how much was tried and what a real system would do. Asked that the demo coach can save and delete things.
- **What Claude did:** Read the course text, the OWASP password and authentication cheat sheets, Google's guide for the client library and the Node documentation for `crypto.argon2`. First built password accounts (Argon2id, a lock after five wrong passwords, the same answer for every failure) with tests that were checked by breaking the lock, the same-answer rule and the lock-while-locked rule one at a time; then deleted them on the team's decision. Wrote `AuthProvider` and `useAuth` so the menu, `ProtectedRoute` and the gymnasts page read the same answer, the login page, the http warning, the confirmation before deleting a gymnast, and `npm run db:demo`. Wrote the tests (including one test of the whole path in `src/App.flow.test.tsx`) and broke four behaviors one at a time to check that a test fails: waiting while the server has not answered, the same state for the page and the menu, a `from` that leaves the site, and resetting the store after a login (this one was not caught by the flow test, which replaces the login functions, so `src/lib/apollo/client.test.ts` was added for it). Wrote `docs/AUTHENTICATION.md`.

## The filters, the sort and the page titles of the Elements page (`src/components/elements/FilterMenu.tsx`, `SortMenu.tsx`, `src/lib/elementFilters.ts`, `src/components/PageTitle.tsx`, `backend/src/catalog/`)

- **Tool:** Claude Code
Expand All @@ -507,3 +513,16 @@
- **Accessibility check of the page** with axe-core, Lighthouse, keyboard-only use, contrast, target sizes, reflow, text spacing and reduced motion. Fixed what was in this pull request (focus lost after "Clear filters", the reason for a disabled filter not visible on touch, the page title) and listed the rest. A screen reader was not used: that has to be done by a person.
- **Fixed the review point:** when the structure of the filters cannot be loaded and the address has filters, the page shows the error with "Try again" and does not search.
- **What was not done by Claude:** nothing was posted or replied to on git without being asked, and the choice to offer bands for vault, the wording of the sort list and which findings to fix were the team's.

## End-to-end tests (`e2e/`, `playwright.config.ts`)

- **Tool:** Claude Code
- **What the team did:** Read the requirement of the assignment ("a form of automated end-to-end testing ... a longer sequence of interactions, and testing of the API") and wrote issue #139. Decided that the tests are run against the virtual machine too, and that atypical use and accessibility are part of them.
- **What Claude did:** Checked what the requirement means and what the repository already had (component tests and API tests, but no test that drives the whole application). Chose Playwright (ready-made, waits for the page by itself) with the official axe-core integration, and wrote the configuration so that the tests start the app themselves or run against any address (`E2E_BASE_URL`). Wrote one long flow, the vault bands, atypical use (wrong addresses, no result, a failing request, keyboard only, the disabled filter), axe-core checks of the page in its main states, and tests of the API over HTTP. Ran them against the real backend and the real catalog with Playwright's own Chromium and with Edge, and broke the application on purpose in four places to check that a test fails each time. Wrote the README section, including what the tests do not cover (a screen reader, and a run on every pull request, because the course server has no runners).
- **What was found:** The first run had 4 failing tests, all in the tests and not in the application: Playwright does not click a control that is `aria-disabled`, one expectation was wrong, and a check ran before the new results had arrived (the page keeps the old results on the screen until the new ones are there).

## The vault group on the element page (`src/components/elements/ElementMetadataCard.tsx`, `src/lib/catalogOperations.ts`)

- **Tool:** Claude Code
- **What the team did:** Found in a review that the element page shows the long description of a vault group under "Element group", and wrote issue #125: show "Group N" for a vault, with the same rule as the Elements list.
- **What Claude did:** Added `number` (and `id`, so Apollo's cache can recognise the group across queries) to the `catalogItem` query, and let the element page use `groupLabel` from the Elements list, so the two pages cannot disagree. Wrote one test for an element and one for a vault (with the real group of VT-1.22-a), and checked that each fails when the page shows the name for every item or "Group N" for every item. After a review of its own work, split one test that checked two cases, and replaced test data for the vault that was half an element.
Loading