HATEOAS-first backend framework.
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

765 řádky
15 KiB

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