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.

766 lines
15 KiB

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