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

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