HATEOAS-first backend framework.
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

763 linhas
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 } 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. });