Skip to content

Commit ba1f5f7

Browse files
author
Tom Van Herreweghe
committed
Coding test — Node.js
A Fastify 5 / TypeScript scaffold with the discount service stubbed out, the example orders and product data under fixtures/, and the published floor under verification/. The exercise is in INSTRUCTIONS.md.
0 parents  commit ba1f5f7

32 files changed

Lines changed: 5505 additions & 0 deletions

‎.github/workflows/ci.yml‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: verify
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
verify:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v5
10+
with: { fetch-depth: 0 }
11+
12+
- uses: actions/setup-node@v5
13+
with: { node-version-file: '.nvmrc', cache: npm }
14+
15+
- run: npm ci
16+
17+
- name: Gate — lint, types, tests
18+
run: npm run verify
19+
20+
# Ground one of the review: is this the floor we handed over? Runs here so
21+
# it can be read from the run summary without a local checkout. It is
22+
# deliberately not part of `npm run verify` — the gate is about your code,
23+
# this is about the three protected paths being untouched.
24+
- name: Integrity — the floor, the fixtures and this workflow
25+
if: always()
26+
run: npm run verify:integrity
27+
28+
# Runs even when the gate is red: a reviewer wants to see what the
29+
# service returns most of all when something is failing.
30+
- name: Discount report
31+
if: always()
32+
run: npm run report

‎.gitignore‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Dependencies
2+
node_modules/
3+
4+
# Build output
5+
dist/
6+
build/
7+
*.tsbuildinfo
8+
9+
# Test / coverage output
10+
coverage/
11+
.nyc_output/
12+
13+
# Environment
14+
.env
15+
.env.*
16+
!.env.example
17+
18+
# Logs
19+
*.log
20+
npm-debug.log*
21+
yarn-debug.log*
22+
yarn-error.log*
23+
pnpm-debug.log*
24+
25+
# Caches
26+
.cache/
27+
.npm/
28+
.eslintcache
29+
30+
# Editors
31+
.vscode/
32+
.idea/
33+
*.swp
34+
35+
# OS
36+
.DS_Store
37+
Thumbs.db
38+
39+
# Wayfinding / internal planning — must never reach a public candidate-facing repo,
40+
# not even in history (see map.md, Q2 of the charting session)
41+
.scratch/

‎.nvmrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
24

‎AGENTS.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# AGENTS.md
2+
3+
Notes for a coding agent working in this repo.
4+
5+
This file is a working aid, not a deliverable. We do not read it and we do not grade it, and it
6+
is not pinned — edit it, extend it, or delete it as suits you.
7+
8+
## Do not edit these paths
9+
10+
`fixtures/`, `verification/` and `.github/workflows/` are the published floor.
11+
12+
`npm run verify:integrity` compares them against this repo's first commit — the one that was
13+
handed over — and the submission includes that output. A floor edited to make a check pass is
14+
read as a floor not passed.
15+
16+
**One check is red on a fresh clone**: `declares at least one argument`, in
17+
`verification/tool.floor.test.ts`. That is a task, not a break — a tool with no arguments cannot
18+
be told which order to price. Fix it in `src/tools/`, never in `verification/`.
19+
20+
When a floor test fails, the code under `src/` is what changes.
21+
22+
## The gate
23+
24+
```
25+
npm run verify # lint + types + tests
26+
```
27+
28+
`lint` enforces an architectural boundary as well as style. Dependencies flow one way only:
29+
30+
```
31+
routes/ tools/ -> plugins/app/ -> plugins/external/
32+
```
33+
34+
`routes/` and `tools/` are peers — two doors into the same service, not into each other — so
35+
neither may import the other. Files prefixed with `_` are private to their own module; import the
36+
module's index instead. The lint messages say which rule you hit and why.
37+
38+
## The report
39+
40+
```
41+
npm run report
42+
```
43+
44+
Prints what the service returns for each example order, then invokes the tool the way a model
45+
does when it gets things wrong. It asserts nothing and cannot fail.
46+
47+
It is the only view of what a caller actually sees. Read its output rather than predicting it.

‎INSTRUCTIONS.md‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Coding test: Node.js
2+
3+
We'd like to see how you build things, so here is a small exercise: one problem, and a repo with
4+
the boring parts already done.
5+
6+
A word on what happens to it. What you send us starts a conversation, it isn't a score. We read it,
7+
then we ask you to walk us through it, and the walkthrough is where it counts. Aim for something you
8+
can defend end to end, not something large.
9+
10+
---
11+
12+
## Before you start
13+
14+
### Getting the repo
15+
16+
Click **Use this template** at the top of this repo and work in the repo that gives you. If you'd
17+
prefer, clone it and push to a new repo of your own. Same result.
18+
19+
Please don't fork it. Two reasons:
20+
- GitHub disables Actions on forks by default, so the CI run we read never happens.
21+
- Forks are listed publicly on the repo they came from, which would put your work in front of every other candidate.
22+
23+
### Running it
24+
25+
Node 24 (there's an `.nvmrc`) and npm.
26+
27+
```
28+
npm install
29+
npm run verify # lint, types, tests
30+
npm run dev # the server, on :3000
31+
npm run report # what your service and your tool actually return
32+
```
33+
34+
One check is red on a fresh clone: `declares at least one argument`. Nothing is broken. An AI tool call with
35+
no arguments can't be told which order to price, so that check is waiting for you to resolve it. Everything else is green before you've written a single line.
36+
37+
### The gate
38+
39+
`npm run verify` is the bar and is required to be green when submitting your repo. It runs lint, types
40+
and tests on [the published floor](verification/README.md) and accross the codebase.
41+
42+
The floor checks that your service answers in the agreed shape and that bad input doesn't crash it.
43+
It deliberately checks nothing about the discounts you calculate, or about how you represent money.
44+
Those are yours to decide and yours to explain.
45+
46+
**`fixtures/`, `verification/` and `.github/workflows/` are ours. Please don't edit them.** They are
47+
the floor, and we check them on submission. If a floor test fails, change the code under `src/`.
48+
49+
### What's already here
50+
51+
A working Fastify skeleton with the discount service stubbed out. Dependencies flow one way:
52+
53+
```
54+
routes/ tools/ -> plugins/app/ -> plugins/external/
55+
```
56+
57+
Files prefixed with `_` are private to their own module. This setup is enforced by `npm run lint` and the messages say which rule you hit and why.
58+
59+
This is the boundary our own service uses. Treat it as a starting point. Change it if you can tell
60+
us why.
61+
62+
### Agents
63+
64+
You're welcome to use coding agents. We don't ask for anything about how you used coding agents. We expect you to be able to explain and own all submitted code.
65+
66+
`AGENTS.md` in the root is a working aid for your agent: which paths not to touch, what the gate is,
67+
what the report does. We don't read it and we don't grade it, so edit or delete it as suits you.
68+
69+
### Scope
70+
71+
This exercise is deliberately kept small for fairness. We believe a candidate's available free time shouldn't be a factor in the submitted result.
72+
73+
We'll ask you to walk us through your submission in about 30 minutes, and anything you can't explain
74+
in that conversation counts against you. Extra scope you weren't asked for will be ignored.
75+
76+
---
77+
78+
## The problem: discounts
79+
80+
We need you to build us a small service that calculates discounts for orders.
81+
82+
### How discounts work
83+
84+
For now, there are three possible ways of getting a discount:
85+
86+
- A customer who has already bought for over € 1000 gets a discount of 10% on the whole order.
87+
- For every product of category "Switches" (id 2), when you buy five, you get a sixth for free.
88+
- If you buy two or more products of category "Tools" (id 1), you get a 20% discount on the
89+
cheapest product.
90+
91+
By the way: there may be more ways of granting customers discounts in the future.
92+
93+
### The data
94+
95+
`fixtures/` holds everything the service has to work with.
96+
97+
- `fixtures/customers.json` and `fixtures/products.json` are the customer and product data. Assume
98+
these arrive in the format of the real external API, because they do.
99+
- `fixtures/orders/` has example orders. These are the payloads we will send you.
100+
101+
### What we ask you to build
102+
103+
Four things, and only these four.
104+
105+
1. **The three discount rules, with tests.** We should be able to see that the rules are right without starting the server.
106+
2. **`POST /discounts`.** It takes one of the example orders as its body and returns the discounts that apply, each with a reason a human can act on. The response must carry a `discountTotal` and a `discounts` array whose entries each have an `amount` and a `reason`. The shape of the values, including how you represent money, is yours.
107+
3. **The same logic reachable as a tool call for our product's AI assistant.** `src/tools/calculateDiscount.ts` is stubbed for you, against the descriptor our assistant's tool runtime expects. There is no new logic to write here. It reaches the same service the endpoint reaches. What it does need is a correct implementation for who is calling the tool (see below "Who is calling").
108+
4. **A short README.** `README.md` is a placeholder. Replace it with yours: how to run it, how you structured it, and any assumptions you made along the way.
109+
110+
Those four are the whole scope. We are not looking for more.
111+
112+
### Who is calling
113+
114+
**CHECK WITH TEAM FIREFLY HOW TO IMPROVE THIS PART**
115+
116+
An assistant makes the tool call, never your own code, and there are four things worth knowing about
117+
it. No LLM experience is expected: every one of them is a backend concern.
118+
119+
- **It cannot read your repo.** The tool's `description`, and the `description` on every property
120+
of its input schema, is the whole of its documentation. Anything you leave ambiguous, it guesses.
121+
- **It cannot catch an exception.** A failure has to come back as a value it can act on. That is
122+
what the `ToolResult` shape in the stub is for. Useful means it can tell what to do differently.
123+
- **Everything you return costs it something**, on every later turn of the conversation. Return the
124+
fields needed to answer the question, not everything you happen to have.
125+
- **It cannot be trusted with arithmetic.** Where the answer is a total, compute it and return it
126+
as a field. Don't leave a sum for the caller to work out.
127+
128+
One more, and this one is about the human at the far end. A reason that says `category 2` sends the
129+
assistant, and then the user, off to work out what that means. What it takes to say `Switches`
130+
instead is already in `fixtures/`.
131+
132+
`npm run report` invokes your tool the way a caller does when it gets things wrong, and prints what
133+
came back. It asserts nothing and cannot fail. Read it anyway. It is the only view you get of what
134+
the caller sees.
135+
136+
### What we look at
137+
138+
Not a checklist of techniques. These are outcomes, and there is more than one good way to reach each
139+
of them.
140+
141+
- **Adding a fourth rule is cheap.** Someone should be able to add one without reading the other three first.
142+
- **Bad input fails in a way the caller can act on.** Not a stack trace, not a 500.
143+
- **We can follow your commits.** Small and self-contained, so your reasoning is legible after the fact.
144+
- **A stranger can run it.** Your README is the only support you get.
145+
146+
---
147+
148+
## When you're done
149+
150+
### Submitting
151+
152+
Send us a link to the repository, public or private, whichever you prefer, as long as we can get to
153+
it. If you keep it private, add the GitHub account your recruiter gave you as a collaborator.
154+
155+
Include the output of:
156+
157+
```
158+
npm run verify:integrity
159+
```
160+
161+
It compares `fixtures/`, `verification/` and `.github/workflows/` against this repo's first commit,
162+
the one we handed over, so it works however you got the repo and whatever you commit on top. If it
163+
prints a diff, we'll read the submission as not having passed the floor. It runs in CI too, so a
164+
slip shows up when you push, well before review.
165+
166+
### Questions
167+
168+
If something about the setup or the tooling isn't clear, ask. We'd rather you weren't stuck on our scaffolding.

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Discounts
2+
3+
This file is yours — replace it with your own README when you submit.
4+
5+
The exercise is in [INSTRUCTIONS.md](./INSTRUCTIONS.md).

‎eslint.config.js‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import js from '@eslint/js';
2+
import tseslint from 'typescript-eslint';
3+
import importX from 'eslint-plugin-import-x';
4+
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
5+
6+
export default tseslint.config(
7+
{ ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.ts'] },
8+
js.configs.recommended,
9+
...tseslint.configs.recommended,
10+
importX.flatConfigs.recommended,
11+
importX.flatConfigs.typescript,
12+
{
13+
languageOptions: {
14+
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
15+
},
16+
settings: {
17+
'import-x/resolver-next': [createTypeScriptImportResolver({ alwaysTryTypes: true })],
18+
},
19+
rules: {
20+
'import-x/no-cycle': 'error',
21+
22+
// The architectural boundary, borrowed from the service this test mirrors.
23+
// Dependencies flow downward only: routes and tools -> app -> external.
24+
// routes/ and tools/ are the two callers of the app layer and are peers:
25+
// neither may import the other.
26+
// `_`-prefixed files are private to their own module.
27+
'import-x/no-restricted-paths': [
28+
'error',
29+
{
30+
zones: [
31+
{
32+
target: './src/plugins/external/**',
33+
from: './src/+(plugins/app|routes|tools)/**',
34+
message: 'external/ is infrastructure and must not import app or route code.',
35+
},
36+
{
37+
target: './src/plugins/app/**',
38+
from: './src/+(routes|tools)/**',
39+
message: 'app/ must not import from its callers (routes/, tools/).',
40+
},
41+
{
42+
target: './src/+(routes|tools|plugins/external)/**',
43+
from: './src/plugins/app/*/_*',
44+
message: 'Module internals (_-prefixed) are private. Import the module index instead.',
45+
},
46+
],
47+
},
48+
],
49+
},
50+
},
51+
);

‎fixtures/customers.json‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
{
3+
"id": "1",
4+
"name": "Coca Cola",
5+
"since": "2014-06-28",
6+
"revenue": "492.12"
7+
},
8+
{
9+
"id": "2",
10+
"name": "Teamleader",
11+
"since": "2015-01-15",
12+
"revenue": "1505.95"
13+
},
14+
{
15+
"id": "3",
16+
"name": "Jeroen De Wit",
17+
"since": "2016-02-11",
18+
"revenue": "0.00"
19+
}
20+
]

‎fixtures/orders/order1.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"id": "1",
3+
"customer-id": "1",
4+
"items": [
5+
{
6+
"product-id": "B102",
7+
"quantity": "10",
8+
"unit-price": "4.99",
9+
"total": "49.90"
10+
}
11+
],
12+
"total": "49.90"
13+
}

0 commit comments

Comments
 (0)