HATEOAS-first backend framework.
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

661 lines
13 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. });
  117. afterEach(() => {
  118. Piano.canFetchCollection(false);
  119. });
  120. it('returns data', () => {
  121. return new Promise<void>((resolve, reject) => {
  122. const req = request(
  123. {
  124. host: HOST,
  125. port: PORT,
  126. path: '/api/pianos',
  127. method: 'GET',
  128. headers: {
  129. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  130. 'Accept-Language': 'en',
  131. },
  132. },
  133. (res) => {
  134. res.on('error', (err) => {
  135. reject(err);
  136. });
  137. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  138. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  139. let resBuffer = Buffer.from('');
  140. res.on('data', (c) => {
  141. resBuffer = Buffer.concat([resBuffer, c]);
  142. });
  143. res.on('close', () => {
  144. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  145. const resData = JSON.parse(resBufferJson);
  146. expect(resData).toEqual([]);
  147. resolve();
  148. });
  149. },
  150. );
  151. req.on('error', (err) => {
  152. reject(err);
  153. });
  154. req.end();
  155. });
  156. });
  157. });
  158. describe('serving items', () => {
  159. const data = {
  160. id: 1,
  161. brand: 'Yamaha'
  162. };
  163. beforeEach(async () => {
  164. const resourcePath = join(baseDir, 'pianos.jsonl');
  165. await writeFile(resourcePath, JSON.stringify(data));
  166. });
  167. beforeEach(() => {
  168. Piano.canFetchItem();
  169. });
  170. afterEach(() => {
  171. Piano.canFetchItem(false);
  172. });
  173. it('returns data', () => {
  174. return new Promise<void>((resolve, reject) => {
  175. // TODO all responses should have serialized ids
  176. const req = request(
  177. {
  178. host: HOST,
  179. port: PORT,
  180. path: '/api/pianos/1',
  181. method: 'GET',
  182. headers: {
  183. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  184. },
  185. },
  186. (res) => {
  187. res.on('error', (err) => {
  188. reject(err);
  189. });
  190. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  191. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  192. let resBuffer = Buffer.from('');
  193. res.on('data', (c) => {
  194. resBuffer = Buffer.concat([resBuffer, c]);
  195. });
  196. res.on('close', () => {
  197. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  198. const resData = JSON.parse(resBufferJson);
  199. expect(resData).toEqual(data);
  200. resolve();
  201. });
  202. },
  203. );
  204. req.on('error', (err) => {
  205. reject(err);
  206. });
  207. req.end();
  208. });
  209. });
  210. it('throws on item not found', () => {
  211. return new Promise<void>((resolve, reject) => {
  212. const req = request(
  213. {
  214. host: HOST,
  215. port: PORT,
  216. path: '/api/pianos/2',
  217. method: 'GET',
  218. headers: {
  219. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  220. },
  221. },
  222. (res) => {
  223. res.on('error', (err) => {
  224. Piano.canFetchItem(false);
  225. reject(err);
  226. });
  227. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  228. resolve();
  229. },
  230. );
  231. req.on('error', (err) => {
  232. reject(err);
  233. });
  234. req.end();
  235. });
  236. });
  237. });
  238. describe('creating items', () => {
  239. const data = {
  240. id: 1,
  241. brand: 'Yamaha'
  242. };
  243. const newData = {
  244. brand: 'K. Kawai'
  245. };
  246. beforeEach(async () => {
  247. const resourcePath = join(baseDir, 'pianos.jsonl');
  248. await writeFile(resourcePath, JSON.stringify(data));
  249. });
  250. beforeEach(() => {
  251. Piano.canCreate();
  252. });
  253. afterEach(() => {
  254. Piano.canCreate(false);
  255. });
  256. it('returns data', () => {
  257. return new Promise<void>((resolve, reject) => {
  258. const req = request(
  259. {
  260. host: HOST,
  261. port: PORT,
  262. path: '/api/pianos',
  263. method: 'POST',
  264. headers: {
  265. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  266. 'Content-Type': ACCEPT,
  267. },
  268. },
  269. (res) => {
  270. res.on('error', (err) => {
  271. reject(err);
  272. });
  273. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  274. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  275. let resBuffer = Buffer.from('');
  276. res.on('data', (c) => {
  277. resBuffer = Buffer.concat([resBuffer, c]);
  278. });
  279. res.on('close', () => {
  280. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  281. const resData = JSON.parse(resBufferJson);
  282. expect(resData).toEqual({
  283. ...newData,
  284. id: 2
  285. });
  286. resolve();
  287. });
  288. },
  289. );
  290. req.on('error', (err) => {
  291. reject(err);
  292. });
  293. req.write(JSON.stringify(newData));
  294. req.end();
  295. });
  296. });
  297. });
  298. describe('patching items', () => {
  299. const data = {
  300. id: 1,
  301. brand: 'Yamaha'
  302. };
  303. const newData = {
  304. brand: 'K. Kawai'
  305. };
  306. beforeEach(async () => {
  307. const resourcePath = join(baseDir, 'pianos.jsonl');
  308. await writeFile(resourcePath, JSON.stringify(data));
  309. });
  310. beforeEach(() => {
  311. Piano.canPatch();
  312. });
  313. afterEach(() => {
  314. Piano.canPatch(false);
  315. });
  316. it('returns data', () => {
  317. return new Promise<void>((resolve, reject) => {
  318. const req = request(
  319. {
  320. host: HOST,
  321. port: PORT,
  322. path: `/api/pianos/${data.id}`,
  323. method: 'PATCH',
  324. headers: {
  325. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  326. 'Content-Type': ACCEPT,
  327. },
  328. },
  329. (res) => {
  330. res.on('error', (err) => {
  331. reject(err);
  332. });
  333. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  334. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  335. let resBuffer = Buffer.from('');
  336. res.on('data', (c) => {
  337. resBuffer = Buffer.concat([resBuffer, c]);
  338. });
  339. res.on('close', () => {
  340. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  341. const resData = JSON.parse(resBufferJson);
  342. expect(resData).toEqual({
  343. ...data,
  344. ...newData,
  345. });
  346. resolve();
  347. });
  348. },
  349. );
  350. req.on('error', (err) => {
  351. reject(err);
  352. });
  353. req.write(JSON.stringify(newData));
  354. req.end();
  355. });
  356. });
  357. it('throws on item to patch not found', () => {
  358. return new Promise<void>((resolve, reject) => {
  359. const req = request(
  360. {
  361. host: HOST,
  362. port: PORT,
  363. path: '/api/pianos/2',
  364. method: 'PATCH',
  365. headers: {
  366. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  367. 'Content-Type': ACCEPT,
  368. },
  369. },
  370. (res) => {
  371. res.on('error', (err) => {
  372. reject(err);
  373. });
  374. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  375. resolve();
  376. },
  377. );
  378. req.on('error', (err) => {
  379. reject(err);
  380. });
  381. req.write(JSON.stringify(newData));
  382. req.end();
  383. });
  384. });
  385. });
  386. describe('emplacing items', () => {
  387. const data = {
  388. id: 1,
  389. brand: 'Yamaha'
  390. };
  391. const newData = {
  392. id: 1,
  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.canEmplace();
  401. });
  402. afterEach(() => {
  403. Piano.canEmplace(false);
  404. });
  405. it('returns data for replacement', () => {
  406. return new Promise<void>((resolve, reject) => {
  407. const req = request(
  408. {
  409. host: HOST,
  410. port: PORT,
  411. path: `/api/pianos/${newData.id}`,
  412. method: 'PUT',
  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(newData);
  432. resolve();
  433. });
  434. },
  435. );
  436. req.on('error', (err) => {
  437. reject(err);
  438. });
  439. req.write(JSON.stringify(newData));
  440. req.end();
  441. });
  442. });
  443. it('returns data for creation', () => {
  444. return new Promise<void>((resolve, reject) => {
  445. const id = 2;
  446. const req = request(
  447. {
  448. host: HOST,
  449. port: PORT,
  450. path: `/api/pianos/${id}`,
  451. method: 'PUT',
  452. headers: {
  453. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  454. 'Content-Type': ACCEPT,
  455. },
  456. },
  457. (res) => {
  458. res.on('error', (err) => {
  459. reject(err);
  460. });
  461. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  462. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  463. let resBuffer = Buffer.from('');
  464. res.on('data', (c) => {
  465. resBuffer = Buffer.concat([resBuffer, c]);
  466. });
  467. res.on('close', () => {
  468. const resBufferJson = resBuffer.toString(ACCEPT_CHARSET);
  469. const resData = JSON.parse(resBufferJson);
  470. expect(resData).toEqual({
  471. ...newData,
  472. id,
  473. });
  474. resolve();
  475. });
  476. },
  477. );
  478. req.on('error', (err) => {
  479. reject(err);
  480. });
  481. req.write(JSON.stringify({
  482. ...newData,
  483. id,
  484. }));
  485. req.end();
  486. });
  487. });
  488. });
  489. describe('deleting items', () => {
  490. const data = {
  491. id: 1,
  492. brand: 'Yamaha'
  493. };
  494. beforeEach(async () => {
  495. const resourcePath = join(baseDir, 'pianos.jsonl');
  496. await writeFile(resourcePath, JSON.stringify(data));
  497. });
  498. beforeEach(() => {
  499. Piano.canDelete();
  500. });
  501. afterEach(() => {
  502. Piano.canDelete(false);
  503. });
  504. it('returns data', () => {
  505. return new Promise<void>((resolve, reject) => {
  506. const req = request(
  507. {
  508. host: HOST,
  509. port: PORT,
  510. path: `/api/pianos/${data.id}`,
  511. method: 'DELETE',
  512. headers: {
  513. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  514. },
  515. },
  516. (res) => {
  517. res.on('error', (err) => {
  518. reject(err);
  519. });
  520. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  521. resolve();
  522. },
  523. );
  524. req.on('error', (err) => {
  525. reject(err);
  526. });
  527. req.end();
  528. });
  529. });
  530. it('throws on item not found', () => {
  531. return new Promise<void>((resolve, reject) => {
  532. const req = request(
  533. {
  534. host: HOST,
  535. port: PORT,
  536. path: '/api/pianos/2',
  537. method: 'DELETE',
  538. headers: {
  539. 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`,
  540. },
  541. },
  542. (res) => {
  543. res.on('error', (err) => {
  544. reject(err);
  545. });
  546. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  547. resolve();
  548. },
  549. );
  550. req.on('error', (err) => {
  551. reject(err);
  552. });
  553. req.end();
  554. });
  555. });
  556. });
  557. // https://github.com/mayajs/maya/blob/main/test/index.test.ts
  558. //
  559. // peak unit test
  560. describe("Contribute to see a unit test", () => {
  561. test("should have a unit test", () => {
  562. expect("Is this a unit test?").not.toEqual("Yes this is a unit test.");
  563. });
  564. });
  565. });