DEV Community

Cover image for Every test was green. Then I deployed it.
Onkar Deokate
Onkar Deokate

Posted on AI-assisted

Every test was green. Then I deployed it.

ParkEase going live: the website, the admin panel and the app, side by side.

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:

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'
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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 = `
Enter fullscreen mode Exit fullscreen mode

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()) }),
  }),
});
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

The app now uses that one instead of its copy:

const searchResponseSchema = z.object({
  data: z.array(spaceSearchItemSchema),
  meta: cursorPageMetaSchema,
});
Enter fullscreen mode Exit fullscreen mode

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 nextCursor out; the real one sends null

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)

Collapse
 
arhancanli profile image
Arhan Canli •

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 null where the schema says optional fails 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.url and not copied into dist/ 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.

Collapse
 
preetest profile image
Preetest Team •

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):

  • The marketing site is in good shape: CSP, HSTS, X-Frame-Options DENY, nosniff, a real 404 for an unknown path, one H1, a meta description, lang=en-IN.
  • The app and the admin panel send none of those headers except HSTS. The admin panel with no X-Frame-Options / frame-ancestors is the one I would fix first: it can be framed by any site.
  • Both the app and the admin return 200 for /this-page-does-not-exist (soft 404), have no beyond the name, no meta description and no H1. Fine for an app shell, but worth a real 404 route.</li> <li>On the website the CSP allows 'unsafe-inline' for scripts, which mostly cancels the point of having one. If it is Next.js, a nonce from middleware is the usual way out; otherwise moving the inline scripts into files.</li> </ul> <p>None of that would show up in an integration suite, which is exactly your point: the deploy is a different environment from the test. Disclosure: I'm on the team behind Preetest, an AI website-testing platform by Yamama Software, and this is the class of thing it reports on a live URL, but the four checks above cost nothing and I'd run them on every deploy regardless.</p>