← Back to Work

event-rsvp: A Test-Automation Showcase (Six Bugs, On Purpose)

My portfolio had plenty of things I'd built, nothing that proved I can actually test. So I built a small app, seeded six real bugs into it on purpose, and let a full test pyramid catch them — with the receipts. Then I pushed it further than planned: a real production deployment, which found three more bugs the test pyramid never could.

6bugs seeded on purpose
18automated tests, 4 layers
51→50overbooking, fixed
13xslower in production (real finding)

Why a demo app instead of a real project

Most QA portfolios are claims: "experienced with Playwright," "strong in test automation," a list of tools on a CV. None of that is verifiable — a recruiter can't click into a claim. I wanted something they actually could: real test code, a real CI pipeline that runs on every push, and bugs with a visible paper trail from "caught" to "fixed."

A demo app, built specifically to have bugs, sounds like cheating. It isn't, as long as the bugs are the kind that actually happen — not contrived edge cases nobody would write. I picked an event registration system with a waitlist for exactly that reason: it has a natural concurrency problem (two people grabbing the last seat at once), a natural state machine (confirmed → waitlisted → promoted → cancelled), and natural places for sloppy validation. All real bug classes, just on a schedule I controlled.

The app, and the six bugs

Register for an event with limited seats. Once it's full, you join a waitlist; if someone cancels, the next person in line gets promoted automatically. Simple on purpose — the app isn't the point, the testing is.

Screenshot of the event-rsvp registration form showing the Jakarta QA Meetup event, seat count, and the registration fields.

The actual app. Nothing fancy — the test pyramid is the point.

The first commit shipped with six bugs, seeded on purpose, one per layer of the pyramid I planned to build:

What the test pyramid actually caught

Four layers, each aimed at a different failure mode: Vitest for business-logic unit tests, Playwright's request fixture for API/contract tests (status codes, error shapes — a layer I skipped at first and had to go back and add), Playwright + axe-core for browser E2E and automated accessibility scans, and k6 for load testing. 18 automated tests in total, all running in CI on every push.

The unit tests caught the off-by-one and the validation gap immediately — real failing-test output, not a retelling:

FAIL  registerForEvent — capacity boundary (off-by-one)
Expected: "waitlisted"
Received: "confirmed"

FAIL  registerForEvent — email validation
Expected: "error"
Received: "confirmed"
Terminal output showing 4 failing Vitest tests against the deliberately buggy first commit.

The actual failing output, from the actual buggy first commit — not a reenactment.

The load test against the race condition is where it gets interesting. 50 virtual users, 500 requests, a 50-seat event. Before the fix: 51 confirmed — overbooked by exactly one. After: exactly 50. Clean, consistent, reproducible. But it's a smaller effect than I expected going in, and the honest reason is worth saying out loud: Node.js is single-threaded and the local database driver (better-sqlite3) is synchronous, which narrows how badly two requests can actually race each other. A multi-process stack would very likely show a much worse number under the exact same bug.

The axe-core scan caught the planned accessibility bugs, then kept going and found two more I hadn't planned: a <p> rendered as a direct child of a <ul> (invalid list structure), and a second insufficient-contrast color I'd missed because I was only thinking about the one badge I'd deliberately broken. That's the actual value of running a real scanner instead of just fixing the bug you set out to seed — it finds things outside the plan.

The bug I claimed and then had to retract

I wrote in my own bug log that the waitlist-promotion race was "caught by a k6 load test" — before I'd actually built that scenario. When I went back and built it properly (two confirmed seats, cancelled concurrently, checking whether both waitlisted people get promoted), I ran it four times against the deliberately buggy code and never once reproduced the bug. Same root cause as the race condition above: this runtime's concurrency model doesn't let the two operations interleave at the JavaScript level, transaction or not.

I corrected the claim rather than leave it. The fix (a proper database transaction around the cancel-and-promote step) is still worth keeping — it's real protection against a different deployment shape — but "caught by a test" and "theoretically possible, never actually reproduced" are different claims, and only one of them was true.

Taking it to production found three more bugs

The original plan stopped at a local demo. I pushed further: a real Postgres database (Neon) and a real deployment (Vercel), partly to get a live link people could actually click, and partly to test a hypothesis — would the race condition that never reproduced locally show up once requests hit genuinely separate server instances?

Getting there surfaced three real failures, none of them guessable from reading the code:

GitHub Actions CI run showing all 3 jobs passing: Unit tests, E2E + accessibility, and Production build.

CI green on the commit that documents all three deployment failures — the fixes shipped with the honesty, not instead of it.

And the hypothesis itself: did the race condition show up on real infrastructure? No — zero overbooking, exactly 50 confirmed out of 50 seats, even under genuine multi-instance concurrent load. The fix holds. What I didn't expect was the actual finding: requests against the deployed version averaged 10 seconds, against 750 milliseconds locally — roughly 13x slower, from serverless cold-start and cross-region database connection overhead. Not the bug I went looking for, but a real, honestly more useful lesson: architecture choices trade one risk for another, and "it works" isn't the same question as "it works fast."

Try it / read the code

Live demo: event-rsvp-red.vercel.app. Full source, every commit from "intentionally broken" to "fixed with receipts," the complete bug log, and every piece of evidence referenced above: github.com/jhonsalya11/event-rsvp.

PS: the live demo resets occasionally when I re-run the load test against it for fun. If you show up and it's suspiciously close to full, that's why — not a bug, just me testing my own test.