HATEOAS-first backend framework.
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

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