HATEOAS-first backend framework.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

default.test.ts 11 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import {
  2. beforeAll,
  3. afterAll,
  4. afterEach,
  5. beforeEach,
  6. describe,
  7. expect,
  8. it,
  9. } from 'vitest';
  10. import {
  11. tmpdir
  12. } from 'os';
  13. import {
  14. mkdtemp,
  15. rm,
  16. writeFile,
  17. } from 'fs/promises';
  18. import {
  19. join
  20. } from 'path';
  21. import {constants} from 'http2';
  22. import {Backend, dataSources} from '../../../src/backend';
  23. import { application, resource, validation as v, Resource } from '../../../src/common';
  24. import { autoIncrement } from '../../fixtures';
  25. import {createTestClient, TestClient} from '../../utils';
  26. const PORT = 3000;
  27. const HOST = '127.0.0.1';
  28. const BASE_PATH = '/api';
  29. const ACCEPT = 'application/json';
  30. const ACCEPT_LANGUAGE = 'en';
  31. const ACCEPT_CHARSET = 'utf-8';
  32. const CONTENT_TYPE_CHARSET = 'utf-8';
  33. const CONTENT_TYPE = ACCEPT;
  34. describe('happy path', () => {
  35. let client: TestClient;
  36. beforeEach(() => {
  37. client = createTestClient({
  38. host: HOST,
  39. port: PORT,
  40. })
  41. .acceptMediaType(ACCEPT)
  42. .acceptLanguage(ACCEPT_LANGUAGE)
  43. .acceptCharset(ACCEPT_CHARSET)
  44. .contentType(CONTENT_TYPE)
  45. .contentCharset(CONTENT_TYPE_CHARSET);
  46. });
  47. let baseDir: string;
  48. beforeAll(async () => {
  49. try {
  50. baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
  51. } catch {
  52. // noop
  53. }
  54. });
  55. afterAll(async () => {
  56. try {
  57. await rm(baseDir, {
  58. recursive: true,
  59. });
  60. } catch {
  61. // noop
  62. }
  63. });
  64. let Piano: Resource;
  65. beforeEach(() => {
  66. Piano = resource(v.object(
  67. {
  68. brand: v.string()
  69. },
  70. v.never()
  71. ))
  72. .name('Piano' as const)
  73. .route('pianos' as const)
  74. .id('id' as const, {
  75. generationStrategy: autoIncrement,
  76. serialize: (id) => id?.toString() ?? '0',
  77. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  78. schema: v.number(),
  79. });
  80. });
  81. let backend: Backend;
  82. let server: ReturnType<Backend['createHttpServer']>;
  83. beforeEach(() => {
  84. const app = application({
  85. name: 'piano-service',
  86. })
  87. .resource(Piano);
  88. backend = app.createBackend({
  89. dataSource: new dataSources.jsonlFile.DataSource(baseDir),
  90. });
  91. server = backend.createHttpServer({
  92. basePath: BASE_PATH
  93. });
  94. return new Promise((resolve, reject) => {
  95. server.on('error', (err) => {
  96. reject(err);
  97. });
  98. server.on('listening', () => {
  99. resolve();
  100. });
  101. server.listen({
  102. port: PORT
  103. });
  104. });
  105. });
  106. afterEach(() => new Promise((resolve, reject) => {
  107. server.close((err) => {
  108. if (err) {
  109. reject(err);
  110. }
  111. resolve();
  112. });
  113. }));
  114. describe('serving collections', () => {
  115. beforeEach(() => {
  116. Piano.canFetchCollection();
  117. return new Promise((resolve) => {
  118. setTimeout(() => {
  119. resolve();
  120. });
  121. });
  122. });
  123. afterEach(() => {
  124. Piano.canFetchCollection(false);
  125. });
  126. it('returns data', async () => {
  127. const [res, resData] = await client({
  128. method: 'GET',
  129. path: `${BASE_PATH}/pianos`,
  130. });
  131. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  132. // TODO test status messages
  133. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  134. if (typeof resData === 'undefined') {
  135. expect.fail('Response body must be defined.');
  136. return;
  137. }
  138. expect(resData).toEqual([]);
  139. });
  140. it('returns data on HEAD method', async () => {
  141. const [res] = await client({
  142. method: 'HEAD',
  143. path: `${BASE_PATH}/pianos`,
  144. });
  145. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  146. });
  147. it('returns options', async () => {
  148. const [res] = await client({
  149. method: 'OPTIONS',
  150. path: `${BASE_PATH}/pianos`,
  151. });
  152. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  153. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  154. expect(allowedMethods).toContain('GET');
  155. expect(allowedMethods).toContain('HEAD');
  156. });
  157. });
  158. describe('serving items', () => {
  159. const existingResource = {
  160. id: 1,
  161. brand: 'Yamaha'
  162. };
  163. beforeEach(async () => {
  164. const resourcePath = join(baseDir, 'pianos.jsonl');
  165. await writeFile(resourcePath, JSON.stringify(existingResource));
  166. });
  167. beforeEach(() => {
  168. Piano.canFetchItem();
  169. return new Promise((resolve) => {
  170. setTimeout(() => {
  171. resolve();
  172. });
  173. });
  174. });
  175. afterEach(() => {
  176. Piano.canFetchItem(false);
  177. });
  178. it('returns data', async () => {
  179. const [res, resData] = await client({
  180. method: 'GET',
  181. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  182. });
  183. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  184. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  185. if (typeof resData === 'undefined') {
  186. expect.fail('Response body must be defined.');
  187. return;
  188. }
  189. expect(resData).toEqual(existingResource);
  190. });
  191. it('returns data on HEAD method', async () => {
  192. const [res] = await client({
  193. method: 'HEAD',
  194. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  195. });
  196. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  197. });
  198. it('returns options', async () => {
  199. const [res] = await client({
  200. method: 'OPTIONS',
  201. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  202. });
  203. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  204. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  205. expect(allowedMethods).toContain('GET');
  206. expect(allowedMethods).toContain('HEAD');
  207. });
  208. });
  209. describe('creating items', () => {
  210. const existingResource = {
  211. id: 1,
  212. brand: 'Yamaha'
  213. };
  214. const newResourceData = {
  215. brand: 'K. Kawai'
  216. };
  217. beforeEach(async () => {
  218. const resourcePath = join(baseDir, 'pianos.jsonl');
  219. await writeFile(resourcePath, JSON.stringify(existingResource));
  220. });
  221. beforeEach(() => {
  222. Piano.canCreate();
  223. });
  224. afterEach(() => {
  225. Piano.canCreate(false);
  226. });
  227. it('returns data', async () => {
  228. const [res, resData] = await client({
  229. path: `${BASE_PATH}/pianos`,
  230. method: 'POST',
  231. body: newResourceData,
  232. });
  233. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  234. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  235. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`);
  236. if (typeof resData === 'undefined') {
  237. expect.fail('Response body must be defined.');
  238. return;
  239. }
  240. expect(resData).toEqual({
  241. ...newResourceData,
  242. id: 2
  243. });
  244. });
  245. it('returns options', async () => {
  246. const [res] = await client({
  247. method: 'OPTIONS',
  248. path: `${BASE_PATH}/pianos`,
  249. });
  250. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  251. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  252. expect(allowedMethods).toContain('POST');
  253. });
  254. });
  255. describe('patching items', () => {
  256. const existingResource = {
  257. id: 1,
  258. brand: 'Yamaha'
  259. };
  260. const patchData = {
  261. brand: 'K. Kawai'
  262. };
  263. beforeEach(async () => {
  264. const resourcePath = join(baseDir, 'pianos.jsonl');
  265. await writeFile(resourcePath, JSON.stringify(existingResource));
  266. });
  267. beforeEach(() => {
  268. Piano.canPatch();
  269. });
  270. afterEach(() => {
  271. Piano.canPatch(false);
  272. });
  273. it('returns data', async () => {
  274. const [res, resData] = await client({
  275. method: 'PATCH',
  276. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  277. body: patchData,
  278. });
  279. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  280. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  281. if (typeof resData === 'undefined') {
  282. expect.fail('Response body must be defined.');
  283. return;
  284. }
  285. expect(resData).toEqual({
  286. ...existingResource,
  287. ...patchData,
  288. });
  289. });
  290. it('returns options', async () => {
  291. const [res] = await client({
  292. method: 'OPTIONS',
  293. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  294. });
  295. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  296. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  297. expect(allowedMethods).toContain('PATCH');
  298. });
  299. });
  300. describe('emplacing items', () => {
  301. const existingResource = {
  302. id: 1,
  303. brand: 'Yamaha'
  304. };
  305. const emplaceResourceData = {
  306. id: 1,
  307. brand: 'K. Kawai'
  308. };
  309. beforeEach(async () => {
  310. const resourcePath = join(baseDir, 'pianos.jsonl');
  311. await writeFile(resourcePath, JSON.stringify(existingResource));
  312. });
  313. beforeEach(() => {
  314. Piano.canEmplace();
  315. });
  316. afterEach(() => {
  317. Piano.canEmplace(false);
  318. });
  319. it('returns data for replacement', async () => {
  320. const [res, resData] = await client({
  321. method: 'PUT',
  322. path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`,
  323. body: emplaceResourceData,
  324. });
  325. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  326. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  327. if (typeof resData === 'undefined') {
  328. expect.fail('Response body must be defined.');
  329. return;
  330. }
  331. expect(resData).toEqual(emplaceResourceData);
  332. });
  333. it('returns data for creation', async () => {
  334. const newId = 2;
  335. const [res, resData] = await client({
  336. method: 'PUT',
  337. path: `${BASE_PATH}/pianos/${newId}`,
  338. body: {
  339. ...emplaceResourceData,
  340. id: newId,
  341. },
  342. });
  343. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  344. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  345. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`);
  346. if (typeof resData === 'undefined') {
  347. expect.fail('Response body must be defined.');
  348. return;
  349. }
  350. expect(resData).toEqual({
  351. ...emplaceResourceData,
  352. id: newId,
  353. });
  354. });
  355. it('returns options', async () => {
  356. const [res] = await client({
  357. method: 'OPTIONS',
  358. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  359. });
  360. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  361. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  362. expect(allowedMethods).toContain('PUT');
  363. });
  364. });
  365. describe('deleting items', () => {
  366. const existingResource = {
  367. id: 1,
  368. brand: 'Yamaha'
  369. };
  370. beforeEach(async () => {
  371. const resourcePath = join(baseDir, 'pianos.jsonl');
  372. await writeFile(resourcePath, JSON.stringify(existingResource));
  373. });
  374. beforeEach(() => {
  375. Piano.canDelete();
  376. });
  377. afterEach(() => {
  378. Piano.canDelete(false);
  379. });
  380. it('responds', async () => {
  381. const [res, resData] = await client({
  382. method: 'DELETE',
  383. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  384. });
  385. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  386. expect(res.headers).not.toHaveProperty('content-type');
  387. expect(resData).toBeUndefined();
  388. });
  389. it('returns options', async () => {
  390. const [res] = await client({
  391. method: 'OPTIONS',
  392. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  393. });
  394. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  395. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  396. expect(allowedMethods).toContain('DELETE');
  397. });
  398. });
  399. });