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

Collapse
 
ayoub_hammoudi profile image
Ayoub Hammoudi •

The Lua file is a classic: tests run from source, prod runs from the artifact. Running the integration suite once per CI run against the built artifact instead of the source tree catches that whole class, every asset the compiler never sees.

Collapse
 
rakno profile image
Onkar Deokate •

Thanks Ayoub, agreed. Running the suite on the build is the general fix, and moving the Lua into a TS string only fixed this one case. Anything else the compiler never sees, like a JSON file or a template or an SQL file read from disk, has the same problem. The plan now is a CI step that builds the image, boots it and waits for the health check. That alone would have caught this in under a second.

Collapse
 
shipmarkco profile image
Shipmark •

Congrats on getting ParkEase live. "Every test was green, then I deployed it" is the most honest launch line I have read this week, and the file the build left behind is a perfect example. Free offer from our side. Run your live site through the quick check at shipmark.co. The one thing we'd look at first is the live app address signed out, in a private window, since your tests ran from source and a stranger runs the shipped version. Free to run.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.