Skip to content

Commit 8d54367

Browse files
authored
Merge pull request #27 from soranjiro/feat/test-new
create test
2 parents b72ab84 + 85a3a52 commit 8d54367

24 files changed

Lines changed: 2206 additions & 11 deletions

.github/workflows/ci.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,35 @@ on:
1313
- "docs/**"
1414

1515
jobs:
16-
build-test:
16+
test:
1717
runs-on: ubuntu-latest
1818
steps:
1919
- uses: actions/checkout@v4
2020

21+
- name: Setup PNPM
22+
uses: pnpm/action-setup@v4
23+
24+
- name: Setup Node.js
25+
uses: actions/setup-node@v4
26+
with:
27+
node-version: "20"
28+
cache: "pnpm"
29+
30+
- name: Install dependencies
31+
run: pnpm install --frozen-lockfile
32+
33+
- name: Run API Tests
34+
run: pnpm run test:api
35+
36+
- name: Run Web Tests
37+
run: pnpm run test:web
38+
39+
build:
40+
runs-on: ubuntu-latest
41+
needs: test
42+
steps:
43+
- uses: actions/checkout@v4
44+
2145
- name: Setup PNPM
2246
uses: pnpm/action-setup@v4
2347

.husky/pre-push

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

Makefile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build deploy deploy-api deploy-web dev migrate-local migrate-remote
1+
.PHONY: build deploy deploy-api deploy-web dev migrate-local migrate-remote test test-api test-web
22

33
build:
44
pnpm run build
@@ -19,3 +19,12 @@ migrate-local:
1919

2020
migrate-remote:
2121
cd apps/api && pnpm wrangler d1 migrations apply tabitabi --remote
22+
23+
test:
24+
pnpm run test
25+
26+
test-api:
27+
pnpm run test:api
28+
29+
test-web:
30+
pnpm run test:web

apps/api/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@
66
"scripts": {
77
"dev": "wrangler dev",
88
"deploy": "wrangler deploy",
9-
"migrations:apply": "wrangler d1 migrations apply tabitabi-db"
9+
"migrations:apply": "wrangler d1 migrations apply tabitabi-db",
10+
"test": "vitest",
11+
"test:run": "vitest run"
1012
},
1113
"dependencies": {
1214
"@tsndr/cloudflare-worker-jwt": "^3.2.0",
1315
"hono": "^4.6.14"
1416
},
1517
"devDependencies": {
18+
"@cloudflare/vitest-pool-workers": "^0.10.10",
1619
"@cloudflare/workers-types": "^4.20250110.0",
1720
"@tabitabi/types": "workspace:*",
1821
"typescript": "^5.6.3",
22+
"vitest": "^3.2.0",
1923
"wrangler": "^4.38.0"
2024
}
2125
}

apps/api/test/env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { Env } from '../src/utils';
2+
3+
declare module 'cloudflare:test' {
4+
interface ProvidedEnv extends Env {}
5+
}

apps/api/test/health.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
env,
3+
createExecutionContext,
4+
waitOnExecutionContext,
5+
} from 'cloudflare:test';
6+
import { describe, it, expect, beforeEach } from 'vitest';
7+
import app from '../src/index';
8+
9+
describe('Health Check', () => {
10+
it('returns OK', async () => {
11+
const request = new Request('http://localhost/health');
12+
const ctx = createExecutionContext();
13+
const response = await app.fetch(request, env, ctx);
14+
await waitOnExecutionContext(ctx);
15+
16+
expect(response.status).toBe(200);
17+
const data = await response.json();
18+
expect(data).toEqual({ status: 'ok', service: 'tabitabi-api' });
19+
});
20+
});

apps/api/test/itineraries.test.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { env } from 'cloudflare:test';
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import app from '../src/index';
4+
5+
async function applyMigrations(db: D1Database) {
6+
const migrations = [
7+
`CREATE TABLE IF NOT EXISTS itineraries (
8+
id TEXT PRIMARY KEY,
9+
title TEXT NOT NULL,
10+
theme_id TEXT NOT NULL DEFAULT 'minimal',
11+
memo TEXT,
12+
password TEXT,
13+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
15+
);`,
16+
`CREATE TABLE IF NOT EXISTS steps (
17+
id TEXT PRIMARY KEY,
18+
itinerary_id TEXT NOT NULL,
19+
title TEXT NOT NULL,
20+
date TEXT NOT NULL,
21+
time TEXT NOT NULL,
22+
location TEXT,
23+
notes TEXT,
24+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
25+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
26+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
27+
);`,
28+
`CREATE INDEX IF NOT EXISTS idx_steps_itinerary ON steps(itinerary_id);`,
29+
`CREATE TABLE IF NOT EXISTS itinerary_secrets (
30+
itinerary_id TEXT PRIMARY KEY,
31+
enabled BOOLEAN DEFAULT FALSE,
32+
offset_minutes INTEGER DEFAULT 60,
33+
created_at TEXT NOT NULL,
34+
updated_at TEXT NOT NULL,
35+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
36+
);`,
37+
`CREATE TABLE IF NOT EXISTS itinerary_walica_settings (
38+
itinerary_id TEXT PRIMARY KEY,
39+
walica_id TEXT NOT NULL,
40+
created_at TEXT NOT NULL,
41+
updated_at TEXT NOT NULL,
42+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
43+
);`,
44+
];
45+
46+
for (const sql of migrations) {
47+
await db.prepare(sql).run();
48+
}
49+
}
50+
51+
describe('Itineraries API', () => {
52+
beforeEach(async () => {
53+
await applyMigrations(env.DB);
54+
await env.DB.prepare('DELETE FROM steps').run();
55+
await env.DB.prepare('DELETE FROM itinerary_secrets').run();
56+
await env.DB.prepare('DELETE FROM itinerary_walica_settings').run();
57+
await env.DB.prepare('DELETE FROM itineraries').run();
58+
});
59+
60+
describe('POST /api/v1/itineraries', () => {
61+
it('creates a new itinerary with title', async () => {
62+
const request = new Request('http://localhost/api/v1/itineraries', {
63+
method: 'POST',
64+
headers: { 'Content-Type': 'application/json' },
65+
body: JSON.stringify({ title: 'Test Trip' }),
66+
});
67+
68+
const response = await app.fetch(request, env);
69+
expect(response.status).toBe(201);
70+
71+
const { success, data } = await response.json() as any;
72+
expect(success).toBe(true);
73+
expect(data.title).toBe('Test Trip');
74+
expect(data.theme_id).toBe('minimal');
75+
expect(data.id).toBeDefined();
76+
expect(data.token).toBeDefined();
77+
});
78+
79+
it('creates itinerary with custom theme', async () => {
80+
const request = new Request('http://localhost/api/v1/itineraries', {
81+
method: 'POST',
82+
headers: { 'Content-Type': 'application/json' },
83+
body: JSON.stringify({ title: 'Custom Theme Trip', theme_id: 'standard' }),
84+
});
85+
86+
const response = await app.fetch(request, env);
87+
expect(response.status).toBe(201);
88+
89+
const { data } = await response.json() as any;
90+
expect(data.theme_id).toBe('standard');
91+
});
92+
93+
it('creates itinerary with memo', async () => {
94+
const request = new Request('http://localhost/api/v1/itineraries', {
95+
method: 'POST',
96+
headers: { 'Content-Type': 'application/json' },
97+
body: JSON.stringify({ title: 'Trip with Memo', memo: 'Remember to pack sunscreen' }),
98+
});
99+
100+
const response = await app.fetch(request, env);
101+
expect(response.status).toBe(201);
102+
103+
const { data } = await response.json() as any;
104+
expect(data.memo).toBe('Remember to pack sunscreen');
105+
});
106+
});
107+
108+
describe('GET /api/v1/itineraries', () => {
109+
it('returns empty array when no itineraries exist', async () => {
110+
const request = new Request('http://localhost/api/v1/itineraries');
111+
const response = await app.fetch(request, env);
112+
113+
expect(response.status).toBe(200);
114+
const { success, data } = await response.json() as any;
115+
expect(success).toBe(true);
116+
expect(data).toEqual([]);
117+
});
118+
119+
it('returns list of itineraries', async () => {
120+
const createRequest = new Request('http://localhost/api/v1/itineraries', {
121+
method: 'POST',
122+
headers: { 'Content-Type': 'application/json' },
123+
body: JSON.stringify({ title: 'Trip 1' }),
124+
});
125+
await app.fetch(createRequest, env);
126+
127+
const createRequest2 = new Request('http://localhost/api/v1/itineraries', {
128+
method: 'POST',
129+
headers: { 'Content-Type': 'application/json' },
130+
body: JSON.stringify({ title: 'Trip 2' }),
131+
});
132+
await app.fetch(createRequest2, env);
133+
134+
const request = new Request('http://localhost/api/v1/itineraries');
135+
const response = await app.fetch(request, env);
136+
137+
expect(response.status).toBe(200);
138+
const { data } = await response.json() as any;
139+
expect(data).toHaveLength(2);
140+
});
141+
});
142+
143+
describe('GET /api/v1/itineraries/:id', () => {
144+
it('returns itinerary by id', async () => {
145+
const createRequest = new Request('http://localhost/api/v1/itineraries', {
146+
method: 'POST',
147+
headers: { 'Content-Type': 'application/json' },
148+
body: JSON.stringify({ title: 'My Trip' }),
149+
});
150+
const createResponse = await app.fetch(createRequest, env);
151+
const { data: created } = await createResponse.json() as any;
152+
153+
const request = new Request(`http://localhost/api/v1/itineraries/${created.id}`);
154+
const response = await app.fetch(request, env);
155+
156+
expect(response.status).toBe(200);
157+
const { data } = await response.json() as any;
158+
expect(data.id).toBe(created.id);
159+
expect(data.title).toBe('My Trip');
160+
});
161+
162+
it('returns 404 for non-existent itinerary', async () => {
163+
const request = new Request('http://localhost/api/v1/itineraries/nonexistent');
164+
const response = await app.fetch(request, env);
165+
166+
expect(response.status).toBe(404);
167+
const { success, error } = await response.json() as any;
168+
expect(success).toBe(false);
169+
expect(error.code).toBe('NOT_FOUND');
170+
});
171+
});
172+
173+
describe('PUT /api/v1/itineraries/:id', () => {
174+
it('updates itinerary title without password protection', async () => {
175+
const createRequest = new Request('http://localhost/api/v1/itineraries', {
176+
method: 'POST',
177+
headers: { 'Content-Type': 'application/json' },
178+
body: JSON.stringify({ title: 'Original Title' }),
179+
});
180+
const createResponse = await app.fetch(createRequest, env);
181+
const { data: created } = await createResponse.json() as any;
182+
183+
const updateRequest = new Request(`http://localhost/api/v1/itineraries/${created.id}`, {
184+
method: 'PUT',
185+
headers: { 'Content-Type': 'application/json' },
186+
body: JSON.stringify({ title: 'Updated Title' }),
187+
});
188+
const response = await app.fetch(updateRequest, env);
189+
190+
expect(response.status).toBe(200);
191+
const { data } = await response.json() as any;
192+
expect(data.title).toBe('Updated Title');
193+
});
194+
195+
it('returns 404 for non-existent itinerary', async () => {
196+
const updateRequest = new Request('http://localhost/api/v1/itineraries/nonexistent', {
197+
method: 'PUT',
198+
headers: { 'Content-Type': 'application/json' },
199+
body: JSON.stringify({ title: 'Updated Title' }),
200+
});
201+
const response = await app.fetch(updateRequest, env);
202+
203+
expect(response.status).toBe(404);
204+
});
205+
});
206+
207+
describe('DELETE /api/v1/itineraries/:id', () => {
208+
it('deletes itinerary without password protection', async () => {
209+
const createRequest = new Request('http://localhost/api/v1/itineraries', {
210+
method: 'POST',
211+
headers: { 'Content-Type': 'application/json' },
212+
body: JSON.stringify({ title: 'To Delete' }),
213+
});
214+
const createResponse = await app.fetch(createRequest, env);
215+
const { data: created } = await createResponse.json() as any;
216+
217+
const deleteRequest = new Request(`http://localhost/api/v1/itineraries/${created.id}`, {
218+
method: 'DELETE',
219+
});
220+
const response = await app.fetch(deleteRequest, env);
221+
222+
expect(response.status).toBe(200);
223+
224+
const getRequest = new Request(`http://localhost/api/v1/itineraries/${created.id}`);
225+
const getResponse = await app.fetch(getRequest, env);
226+
expect(getResponse.status).toBe(404);
227+
});
228+
229+
it('returns 404 for non-existent itinerary', async () => {
230+
const deleteRequest = new Request('http://localhost/api/v1/itineraries/nonexistent', {
231+
method: 'DELETE',
232+
});
233+
const response = await app.fetch(deleteRequest, env);
234+
235+
expect(response.status).toBe(404);
236+
});
237+
});
238+
});

0 commit comments

Comments
 (0)