For five weeks, ParkEase lived on my laptop. Thousands of unit tests. More than a thousand integration tests against a real Postgres with PostGIS. A race of 100 drivers for 10 parking slots, run on every change, ending in exactly 10 bookings.
Yesterday I promised to put it on the internet. Today it's there, and you can use it:
- the app: https://parkease-app-six.vercel.app (tap a role, then any 6-digit code)
- the admin panel: https://parkease-admin.vercel.app
- the website: https://parkease-web-navy.vercel.app
It costs nothing to run. The API and its job worker share one free container, Postgres is on a free plan in Singapore, and the three front ends are free static hosting. Payments, SMS and photo uploads are switched off in this demo, so nothing real can be charged.
Getting there took more than one deploy. Here's what the real thing found that the tests hadn't.
Bug one: the file the build left behind
The first deploy built cleanly, started the worker, and then the API died in under a second:
Error: ENOENT: no such file or directory, open '/app/apps/api/dist/platform/ratelimit/token-bucket.lua'
The rate limiter runs a small Lua script inside Redis, and it read that script from a file sitting next to its own code:
const __dirname = dirname(fileURLToPath(import.meta.url));
const LUA_SCRIPT = readFileSync(join(__dirname, 'token-bucket.lua'), 'utf8');
In development the code runs from source, and the file is right there. In production it runs from the compiled dist/, and the TypeScript compiler copies .ts files into dist/. Only .ts files.
Every test ran from source. Nothing ever ran the thing that actually ships. The fix was to stop having a file to forget: the script is now a string in a TypeScript module.
export const TOKEN_BUCKET_LUA = `
Bug two: the admin panel that had never signed in
API up. I opened the admin panel, clicked sign in, and got "This number does not hold the admin role." For the admin.
I asked the API directly. It answered 201 with an admin session. So the server was right and the panel was refusing a correct answer. Here's what the panel expected back:
const sessionBodySchema = z.object({
data: z.object({
accessToken: z.string().min(1),
expiresIn: z.number().int().positive(),
user: z.object({ id: z.string().uuid(), roles: z.array(z.string()) }),
}),
});
A user object. The API has never sent one. The panel parsed the response, found no user, treated it as a failure, and showed the only failure message it had.
Its tests passed because their fake response had a user. The tests agreed with the panel, the panel disagreed with the API, and no test had both of them in the same room. The panel now reads the user from the access token's own claims, and its tests use the exact body the API returns.
Bug three: "You're offline"
Signed in as a driver, searched for Forum Mall, and the map was replaced with: You're offline. Check your connection.
I wasn't. The search request had come back 200 with two parking spots in it. The app had thrown them away.
The app checked the shape of the search response with its own hand-written copy of the pagination schema:
const searchMetaSchema = z.object({
limit: z.number().int(),
hasMore: z.boolean(),
nextCursor: z.string().optional(),
});
optional() means the field may be missing. On the last page of results, the API doesn't leave it out; it sends null. To a schema, missing and null are different things, so every real search failed validation. The screen then guessed that any error without an HTTP status must be the network, and told a perfectly connected driver they were offline.
The contracts package already had the right schema, the one the API uses:
export const cursorPageMetaSchema = z.object({
limit: z.number().int().positive(),
hasMore: z.boolean(),
nextCursor: z.string().nullable(),
});
The app now uses that one instead of its copy:
const searchResponseSchema = z.object({
data: z.array(spaceSearchItemSchema),
meta: cursorPageMetaSchema,
});
And the error screen asks the shared failure classifier, so "I couldn't read this" is never again reported as "you have no internet".
The pattern
Three bugs, one shape. Each time, the test and the code agreed with each other and disagreed with reality:
- the tests ran from source; production runs from
dist/ - the panel's fake response had a
user; the real one never did - the app's fake search page left
nextCursorout; the real one sendsnull
None of them were caught by more tests. They were caught by running the real build against the real API once. So that's now the first thing I do: boot the built image locally, sign in, search, and only then send it to the host.
What I'd tell myself before starting
A fixture is a claim about another system. When that system is yours, don't write the claim by hand: import the schema the other side uses, or record a real response. The bug is never in the half you're testing.
That's the series so far. Four roles, an admin panel, a website, and now all of it live. What's left is the part no test can do for me: real drivers, real owners, and a real rupee. Follow along.
ParkEase is a peer-to-peer parking marketplace for India, built solo.
Code: https://github.com/Deonkar/parkease · Music: ende.app (CC BY 4.0)

Top comments (2)
The nullable-vs-optional one is the sharpest of the three, because a shared schema only removes the drift if the API side is held to it too. If the contracts package is only imported by the clients, it's still a claim about the server, just a better-placed one. One test closes it: in the API's e2e suite, parse every real response body with the same schema the clients import, so a server change that sends
nullwhere the schema saysoptionalfails there, not in a driver's search screen.For the first bug, the cheap catch is a CI step that boots the built image and waits for its health endpoint. Anything read relative to
import.meta.urland not copied intodist/dies in under a second, exactly like your ENOENT.Did the admin panel's "does not hold the admin role" message come from the same catch-all branch as the offline screen? Both look like one error handler that has only one message for every failure.
Thanks for publishing the live URLs, that is rarer than it should be. Since you asked what the real thing finds that tests do not, I did the ten-second version against the three deploys from here (plain HTTP requests, nothing more):