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 15 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  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 {request, Server} from 'http';
  22. import {constants} from 'http2';
  23. import {DataSource} from '../../src/backend/data-source';
  24. import { dataSources } from '../../src/backend';
  25. import { application, resource, validation as v, Resource } from '../../src';
  26. const PORT = 3000;
  27. const HOST = 'localhost';
  28. const ACCEPT_CHARSET = 'utf-8';
  29. const ACCEPT = 'application/json';
  30. const autoIncrement = async (dataSource: DataSource) => {
  31. const data = await dataSource.getMultiple() as Record<string, string>[];
  32. const highestId = data.reduce<number>(
  33. (highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId),
  34. -Infinity
  35. );
  36. if (Number.isFinite(highestId)) {
  37. return (highestId + 1);
  38. }
  39. return 1;
  40. };
  41. describe('yasumi', () => {
  42. let baseDir: string;
  43. beforeAll(async () => {
  44. try {
  45. baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
  46. } catch {
  47. // noop
  48. }
  49. });
  50. afterAll(async () => {
  51. try {
  52. await rm(baseDir, {
  53. recursive: true,
  54. });
  55. } catch {
  56. // noop
  57. }
  58. });
  59. let Piano: Resource;
  60. beforeEach(() => {
  61. Piano = resource(v.object(
  62. {
  63. brand: v.string()
  64. },
  65. v.never()
  66. ))
  67. .name('Piano' as const)
  68. .route('pianos' as const)
  69. .id('id' as const, {
  70. generationStrategy: autoIncrement as any,
  71. serialize: (id) => id?.toString() ?? '0',
  72. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  73. schema: v.number(),
  74. });
  75. });
  76. let server: Server;
  77. beforeEach(() => {
  78. const app = application({
  79. name: 'piano-service',
  80. })
  81. .resource(Piano);
  82. const backend = app
  83. .createBackend({
  84. dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
  85. })
  86. .throwsErrorOnDeletingNotFound();
  87. server = backend.createHttpServer({
  88. basePath: '/api'
  89. });
  90. return new Promise((resolve, reject) => {
  91. server.on('error', (err) => {
  92. reject(err);
  93. });
  94. server.on('listening', () => {
  95. resolve();
  96. });
  97. server.listen({
  98. port: PORT
  99. });
  100. });
  101. });
  102. afterEach(() => new Promise((resolve, reject) => {
  103. server.close((err) => {
  104. if (err) {
  105. reject(err);
  106. }
  107. resolve();
  108. });
  109. }));
  110. describe('serving collections', () => {
  111. beforeEach(() => {
  112. Piano.canFetchCollection();
  113. return new Promise((resolve) => {
  114. setTimeout(() => {
  115. resolve();
  116. });
  117. });
  118. });
  119. afterEach(() => {
  120. Piano.canFetchCollection(false);
  121. });
  122. it('returns data', () => {
  123. return new Promise<void>((resolve, reject) => {
  124. const req = request(
  125. {
  126. host: HOST,
  127. port: PORT,
  128. path: '/api/pianos',
  129. method: 'GET',
  130. headers: {
  131. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  132. 'Accept-Language': 'en',
  133. },
  134. },
  135. (res) => {
  136. res.on('error', (err) => {
  137. reject(err);
  138. });
  139. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  140. // TODO test status messsages
  141. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  142. let resBuffer = Buffer.from('');
  143. res.on('data', (c) => {
  144. resBuffer = Buffer.concat([resBuffer, c]);
  145. });
  146. res.on('close', () => {
  147. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  148. const resData = JSON.parse(resBufferJson);
  149. expect(resData).toEqual([]);
  150. resolve();
  151. });
  152. },
  153. );
  154. req.on('error', (err) => {
  155. reject(err);
  156. });
  157. req.end();
  158. });
  159. });
  160. it('returns data on HEAD method', () => {
  161. return new Promise<void>((resolve, reject) => {
  162. const req = request(
  163. {
  164. host: HOST,
  165. port: PORT,
  166. path: '/api/pianos',
  167. method: 'HEAD',
  168. headers: {
  169. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  170. 'Accept-Language': 'en',
  171. },
  172. },
  173. (res) => {
  174. res.on('error', (err) => {
  175. reject(err);
  176. });
  177. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  178. resolve();
  179. },
  180. );
  181. req.on('error', (err) => {
  182. reject(err);
  183. });
  184. req.end();
  185. });
  186. });
  187. });
  188. describe('serving items', () => {
  189. const data = {
  190. id: 1,
  191. brand: 'Yamaha'
  192. };
  193. beforeEach(async () => {
  194. const resourcePath = join(baseDir, 'pianos.jsonl');
  195. await writeFile(resourcePath, JSON.stringify(data));
  196. });
  197. beforeEach(() => {
  198. Piano.canFetchItem();
  199. return new Promise((resolve) => {
  200. setTimeout(() => {
  201. resolve();
  202. });
  203. });
  204. });
  205. afterEach(() => {
  206. Piano.canFetchItem(false);
  207. });
  208. it('returns data', () => {
  209. return new Promise<void>((resolve, reject) => {
  210. // TODO all responses should have serialized ids
  211. const req = request(
  212. {
  213. host: HOST,
  214. port: PORT,
  215. path: '/api/pianos/1',
  216. method: 'GET',
  217. headers: {
  218. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  219. },
  220. },
  221. (res) => {
  222. res.on('error', (err) => {
  223. reject(err);
  224. });
  225. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  226. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  227. let resBuffer = Buffer.from('');
  228. res.on('data', (c) => {
  229. resBuffer = Buffer.concat([resBuffer, c]);
  230. });
  231. res.on('close', () => {
  232. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  233. const resData = JSON.parse(resBufferJson);
  234. expect(resData).toEqual(data);
  235. resolve();
  236. });
  237. },
  238. );
  239. req.on('error', (err) => {
  240. reject(err);
  241. });
  242. req.end();
  243. });
  244. });
  245. it('returns data on HEAD method', () => {
  246. return new Promise<void>((resolve, reject) => {
  247. // TODO all responses should have serialized ids
  248. const req = request(
  249. {
  250. host: HOST,
  251. port: PORT,
  252. path: '/api/pianos/1',
  253. method: 'HEAD',
  254. headers: {
  255. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  256. },
  257. },
  258. (res) => {
  259. res.on('error', (err) => {
  260. reject(err);
  261. });
  262. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  263. resolve();
  264. },
  265. );
  266. req.on('error', (err) => {
  267. reject(err);
  268. });
  269. req.end();
  270. });
  271. });
  272. it('throws on item not found', () => {
  273. return new Promise<void>((resolve, reject) => {
  274. const req = request(
  275. {
  276. host: HOST,
  277. port: PORT,
  278. path: '/api/pianos/2',
  279. method: 'GET',
  280. headers: {
  281. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  282. },
  283. },
  284. (res) => {
  285. res.on('error', (err) => {
  286. Piano.canFetchItem(false);
  287. reject(err);
  288. });
  289. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  290. resolve();
  291. },
  292. );
  293. req.on('error', (err) => {
  294. reject(err);
  295. });
  296. req.end();
  297. });
  298. });
  299. it('throws on item not found on HEAD method', () => {
  300. return new Promise<void>((resolve, reject) => {
  301. const req = request(
  302. {
  303. host: HOST,
  304. port: PORT,
  305. path: '/api/pianos/2',
  306. method: 'HEAD',
  307. headers: {
  308. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  309. },
  310. },
  311. (res) => {
  312. res.on('error', (err) => {
  313. Piano.canFetchItem(false);
  314. reject(err);
  315. });
  316. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  317. resolve();
  318. },
  319. );
  320. req.on('error', (err) => {
  321. reject(err);
  322. });
  323. req.end();
  324. });
  325. });
  326. });
  327. describe('creating items', () => {
  328. const data = {
  329. id: 1,
  330. brand: 'Yamaha'
  331. };
  332. const newData = {
  333. brand: 'K. Kawai'
  334. };
  335. beforeEach(async () => {
  336. const resourcePath = join(baseDir, 'pianos.jsonl');
  337. await writeFile(resourcePath, JSON.stringify(data));
  338. });
  339. beforeEach(() => {
  340. Piano.canCreate();
  341. });
  342. afterEach(() => {
  343. Piano.canCreate(false);
  344. });
  345. it('returns data', () => {
  346. return new Promise<void>((resolve, reject) => {
  347. const req = request(
  348. {
  349. host: HOST,
  350. port: PORT,
  351. path: '/api/pianos',
  352. method: 'POST',
  353. headers: {
  354. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  355. 'Content-Type': ACCEPT,
  356. },
  357. },
  358. (res) => {
  359. res.on('error', (err) => {
  360. reject(err);
  361. });
  362. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  363. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  364. let resBuffer = Buffer.from('');
  365. res.on('data', (c) => {
  366. resBuffer = Buffer.concat([resBuffer, c]);
  367. });
  368. res.on('close', () => {
  369. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  370. const resData = JSON.parse(resBufferJson);
  371. expect(resData).toEqual({
  372. ...newData,
  373. id: 2
  374. });
  375. resolve();
  376. });
  377. },
  378. );
  379. req.on('error', (err) => {
  380. reject(err);
  381. });
  382. req.write(JSON.stringify(newData));
  383. req.end();
  384. });
  385. });
  386. });
  387. describe('patching items', () => {
  388. const data = {
  389. id: 1,
  390. brand: 'Yamaha'
  391. };
  392. const newData = {
  393. brand: 'K. Kawai'
  394. };
  395. beforeEach(async () => {
  396. const resourcePath = join(baseDir, 'pianos.jsonl');
  397. await writeFile(resourcePath, JSON.stringify(data));
  398. });
  399. beforeEach(() => {
  400. Piano.canPatch();
  401. });
  402. afterEach(() => {
  403. Piano.canPatch(false);
  404. });
  405. it('returns data', () => {
  406. return new Promise<void>((resolve, reject) => {
  407. const req = request(
  408. {
  409. host: HOST,
  410. port: PORT,
  411. path: `/api/pianos/${data.id}`,
  412. method: 'PATCH',
  413. headers: {
  414. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  415. 'Content-Type': ACCEPT,
  416. },
  417. },
  418. (res) => {
  419. res.on('error', (err) => {
  420. reject(err);
  421. });
  422. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  423. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  424. let resBuffer = Buffer.from('');
  425. res.on('data', (c) => {
  426. resBuffer = Buffer.concat([resBuffer, c]);
  427. });
  428. res.on('close', () => {
  429. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  430. const resData = JSON.parse(resBufferJson);
  431. expect(resData).toEqual({
  432. ...data,
  433. ...newData,
  434. });
  435. resolve();
  436. });
  437. },
  438. );
  439. req.on('error', (err) => {
  440. reject(err);
  441. });
  442. req.write(JSON.stringify(newData));
  443. req.end();
  444. });
  445. });
  446. it('throws on item to patch not found', () => {
  447. return new Promise<void>((resolve, reject) => {
  448. const req = request(
  449. {
  450. host: HOST,
  451. port: PORT,
  452. path: '/api/pianos/2',
  453. method: 'PATCH',
  454. headers: {
  455. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  456. 'Content-Type': ACCEPT,
  457. },
  458. },
  459. (res) => {
  460. res.on('error', (err) => {
  461. reject(err);
  462. });
  463. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  464. resolve();
  465. },
  466. );
  467. req.on('error', (err) => {
  468. reject(err);
  469. });
  470. req.write(JSON.stringify(newData));
  471. req.end();
  472. });
  473. });
  474. });
  475. describe('emplacing items', () => {
  476. const data = {
  477. id: 1,
  478. brand: 'Yamaha'
  479. };
  480. const newData = {
  481. id: 1,
  482. brand: 'K. Kawai'
  483. };
  484. beforeEach(async () => {
  485. const resourcePath = join(baseDir, 'pianos.jsonl');
  486. await writeFile(resourcePath, JSON.stringify(data));
  487. });
  488. beforeEach(() => {
  489. Piano.canEmplace();
  490. });
  491. afterEach(() => {
  492. Piano.canEmplace(false);
  493. });
  494. it('returns data for replacement', () => {
  495. return new Promise<void>((resolve, reject) => {
  496. const req = request(
  497. {
  498. host: HOST,
  499. port: PORT,
  500. path: `/api/pianos/${newData.id}`,
  501. method: 'PUT',
  502. headers: {
  503. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  504. 'Content-Type': ACCEPT,
  505. },
  506. },
  507. (res) => {
  508. res.on('error', (err) => {
  509. reject(err);
  510. });
  511. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  512. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  513. let resBuffer = Buffer.from('');
  514. res.on('data', (c) => {
  515. resBuffer = Buffer.concat([resBuffer, c]);
  516. });
  517. res.on('close', () => {
  518. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  519. const resData = JSON.parse(resBufferJson);
  520. expect(resData).toEqual(newData);
  521. resolve();
  522. });
  523. },
  524. );
  525. req.on('error', (err) => {
  526. reject(err);
  527. });
  528. req.write(JSON.stringify(newData));
  529. req.end();
  530. });
  531. });
  532. it('returns data for creation', () => {
  533. return new Promise<void>((resolve, reject) => {
  534. const id = 2;
  535. const req = request(
  536. {
  537. host: HOST,
  538. port: PORT,
  539. path: `/api/pianos/${id}`,
  540. method: 'PUT',
  541. headers: {
  542. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  543. 'Content-Type': ACCEPT,
  544. },
  545. },
  546. (res) => {
  547. res.on('error', (err) => {
  548. reject(err);
  549. });
  550. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  551. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  552. let resBuffer = Buffer.from('');
  553. res.on('data', (c) => {
  554. resBuffer = Buffer.concat([resBuffer, c]);
  555. });
  556. res.on('close', () => {
  557. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  558. const resData = JSON.parse(resBufferJson);
  559. expect(resData).toEqual({
  560. ...newData,
  561. id,
  562. });
  563. resolve();
  564. });
  565. },
  566. );
  567. req.on('error', (err) => {
  568. reject(err);
  569. });
  570. req.write(JSON.stringify({
  571. ...newData,
  572. id,
  573. }));
  574. req.end();
  575. });
  576. });
  577. });
  578. describe('deleting items', () => {
  579. const data = {
  580. id: 1,
  581. brand: 'Yamaha'
  582. };
  583. beforeEach(async () => {
  584. const resourcePath = join(baseDir, 'pianos.jsonl');
  585. await writeFile(resourcePath, JSON.stringify(data));
  586. });
  587. beforeEach(() => {
  588. Piano.canDelete();
  589. });
  590. afterEach(() => {
  591. Piano.canDelete(false);
  592. });
  593. it('returns data', () => {
  594. return new Promise<void>((resolve, reject) => {
  595. const req = request(
  596. {
  597. host: HOST,
  598. port: PORT,
  599. path: `/api/pianos/${data.id}`,
  600. method: 'DELETE',
  601. headers: {
  602. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  603. },
  604. },
  605. (res) => {
  606. res.on('error', (err) => {
  607. reject(err);
  608. });
  609. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  610. resolve();
  611. },
  612. );
  613. req.on('error', (err) => {
  614. reject(err);
  615. });
  616. req.end();
  617. });
  618. });
  619. it('throws on item not found', () => {
  620. return new Promise<void>((resolve, reject) => {
  621. const req = request(
  622. {
  623. host: HOST,
  624. port: PORT,
  625. path: '/api/pianos/2',
  626. method: 'DELETE',
  627. headers: {
  628. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  629. },
  630. },
  631. (res) => {
  632. res.on('error', (err) => {
  633. reject(err);
  634. });
  635. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  636. resolve();
  637. },
  638. );
  639. req.on('error', (err) => {
  640. reject(err);
  641. });
  642. req.end();
  643. });
  644. });
  645. });
  646. });