Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Main pipeline

on:
push:
branches: [main]

jobs:
unit-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: fruit-api
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: fruit-api/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npm test

build-and-test:
needs: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: fruit-api/package-lock.json

- name: Install dependencies
run: npm ci
working-directory: fruit-api

- name: Build Docker image
run: docker build -t fruit-api ./fruit-api

- name: Run container
run: |
docker run -d -p 3000:3000 --name fruit-api-test fruit-api
sleep 5

- name: Run integration tests
run: npm run test:integration
working-directory: fruit-api
env:
BASE_URL: http://localhost:3000

- name: Stop container
run: docker stop fruit-api-test

push-image:
needs: build-and-test
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/checkout@v4

- name: Log in to GitHub Packages
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

- name: Build and push image
run: |
docker build -t ghcr.io/${{ github.repository_owner }}/fruit-api:latest ./fruit-api
docker push ghcr.io/${{ github.repository_owner }}/fruit-api:latest
26 changes: 26 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: PR checks

on:
pull_request:
branches: [main]

jobs:
unit-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: fruit-api
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: fruit-api/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npm test
88 changes: 88 additions & 0 deletions fruit-api/__tests__/integration/fruits.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"

async function req(method: string, path: string, body?: unknown) {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
})
let data: unknown
try { data = await res.json() } catch { data = null }
return { status: res.status, data }
}

describe("GET /health", () => {
it("returns 200 with status ok", async () => {
const { status, data } = await req("GET", "/api/health")
expect(status).toBe(200)
expect(data).toEqual({ status: "ok" })
})
})

describe("CRUD lifecycle", () => {
let createdId: string

beforeAll(async () => {
const { data } = await req("POST", "/api/fruits", {
name: "Integration Mango", price: 2.5, in_season: true,
})
createdId = (data as Record<string, unknown>).id as string
})

it("POST creates a fruit", async () => {
expect(typeof createdId).toBe("string")
expect(createdId.length).toBeGreaterThan(0)
})

it("GET by id returns the fruit", async () => {
const { status } = await req("GET", `/api/fruits/${createdId}`)
expect(status).toBe(200)
})

it("GET list includes the new fruit", async () => {
const { data } = await req("GET", "/api/fruits")
const list = data as Array<Record<string, unknown>>
expect(list.some((f) => f.id === createdId)).toBe(true)
})

it("PUT updates the fruit", async () => {
const { status, data } = await req("PUT", `/api/fruits/${createdId}`, { price: 3.0 })
expect(status).toBe(200)
expect((data as Record<string, unknown>).price).toBe(3.0)
})

it("DELETE returns 204", async () => {
const { status } = await req("DELETE", `/api/fruits/${createdId}`)
expect(status).toBe(204)
})

it("GET after DELETE returns 404", async () => {
const { status } = await req("GET", `/api/fruits/${createdId}`)
expect(status).toBe(404)
})
})

describe("cheapest consistency", () => {
it("price matches minimum from list", async () => {
await req("POST", "/api/fruits", { name: "Apple", price: 1.5, in_season: true })
await req("POST", "/api/fruits", { name: "Banana", price: 0.6, in_season: false })
const { data: listData } = await req("GET", "/api/fruits")
const list = listData as Array<{ price: number }>
const minPrice = Math.min(...list.map((f) => f.price))
const { status, data } = await req("GET", "/api/fruits/cheapest")
expect(status).toBe(200)
expect((data as { price: number }).price).toBe(minPrice)
})
})

describe("POST with invalid body", () => {
it("returns 422 for empty body", async () => {
const { status } = await req("POST", "/api/fruits", {})
expect(status).toBe(422)
})

it("returns 422 for missing name", async () => {
const { status } = await req("POST", "/api/fruits", { price: 1.0, in_season: true })
expect(status).toBe(422)
})
})
127 changes: 127 additions & 0 deletions fruit-api/__tests__/unit/fruitStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import store from "../../store/fruitStore"
import { validateCreateFruit, validateUpdateFruit } from "../../lib/validation"

beforeEach(() => {
store.clear()
})

describe("buildFruitJson helper", () => {
it("returns the expected structure for given input", () => {
const fruit = store.create({ name: "Apple", price: 1.5, in_season: true })
expect(fruit).toMatchObject({
id: expect.any(String),
name: "Apple",
price: 1.5,
in_season: true,
created_at: expect.any(String),
})
})
})

describe("store.getAll", () => {
it("returns all fruits from fixture data", () => {
store.create({ name: "Apple", price: 1.5, in_season: true })
store.create({ name: "Banana", price: 0.8, in_season: false })
const fruits = store.getAll()
expect(fruits).toHaveLength(2)
})
})

describe("store.getCheapest", () => {
it("returns the cheapest fruit", () => {
store.create({ name: "Apple", price: 1.5, in_season: true })
store.create({ name: "Banana", price: 0.8, in_season: false })
store.create({ name: "Mango", price: 3.0, in_season: true })
const cheapest = store.getCheapest()
expect(cheapest!.name).toBe("Banana")
expect(cheapest!.price).toBe(0.8)
})

it("returns undefined when store is empty", () => {
expect(store.getCheapest()).toBeUndefined()
})
})

describe("store.getAll with in_season filter", () => {
beforeEach(() => {
store.create({ name: "Apple", price: 1.5, in_season: true })
store.create({ name: "Banana", price: 0.8, in_season: false })
store.create({ name: "Mango", price: 3.0, in_season: true })
})

it("returns only in-season fruits", () => {
const fruits = store.getAll(true)
expect(fruits).toHaveLength(2)
expect(fruits.every((f) => f.in_season === true)).toBe(true)
})

it("returns only out-of-season fruits", () => {
const fruits = store.getAll(false)
expect(fruits).toHaveLength(1)
expect(fruits[0].name).toBe("Banana")
})
})

describe("store.getById", () => {
it("returns undefined for unknown id", () => {
expect(store.getById("nonexistent-id")).toBeUndefined()
})
})

describe("store.delete", () => {
it("returns false for unknown id", () => {
expect(store.delete("nonexistent-id")).toBe(false)
})
})

describe("store.update", () => {
it("returns undefined for unknown id", () => {
expect(store.update("nonexistent-id", { name: "X" })).toBeUndefined()
})
})

describe("validateCreateFruit", () => {
it("accepts a valid body", () => {
const { data, errors } = validateCreateFruit({ name: "Apple", price: 1.5, in_season: true })
expect(errors).toBeUndefined()
expect(data).toEqual({ name: "Apple", price: 1.5, in_season: true })
})

it("returns errors when name is missing", () => {
const { errors } = validateCreateFruit({ price: 1.5, in_season: true })
expect(errors!.some((e) => e.field === "name")).toBe(true)
})

it("returns errors when price is wrong type", () => {
const { errors } = validateCreateFruit({ name: "Apple", price: "free", in_season: true })
expect(errors!.some((e) => e.field === "price")).toBe(true)
})

it("returns errors when in_season is missing", () => {
const { errors } = validateCreateFruit({ name: "Apple", price: 1.5 })
expect(errors!.some((e) => e.field === "in_season")).toBe(true)
})

it("returns errors for empty body", () => {
const { errors } = validateCreateFruit({})
expect(errors!.length).toBeGreaterThan(0)
})
})

describe("validateUpdateFruit", () => {
it("returns errors when price is wrong type", () => {
const { errors } = validateUpdateFruit({ price: "expensive" })
expect(errors!.some((e) => e.field === "price")).toBe(true)
})

it("returns errors when in_season is wrong type", () => {
const { errors } = validateUpdateFruit({ in_season: "yes" })
expect(errors!.some((e) => e.field === "in_season")).toBe(true)
})

it("accepts partial update with only name", () => {
const { data, errors } = validateUpdateFruit({ name: "Orange" })
expect(errors).toBeUndefined()
expect(data).toEqual({ name: "Orange" })
})
})
10 changes: 10 additions & 0 deletions fruit-api/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const config = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/unit/**/*.test.ts"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: { moduleResolution: "node" } }],
},
}

export default config
11 changes: 11 additions & 0 deletions fruit-api/jest.integration.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const config = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/integration/**/*.test.ts"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: { moduleResolution: "node" } }],
},
testTimeout: 30000,
}

export default config
Loading
Loading