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.

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