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.

662 rader
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. })
  80. });
  81. let server: Server;
  82. beforeEach(() => {
  83. const app = application({
  84. name: 'piano-service',
  85. dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
  86. })
  87. .contentType(ACCEPT, serializers.applicationJson)
  88. .encoding(ACCEPT_ENCODING, encodings.utf8)
  89. .resource(Piano);
  90. server = app.createServer({
  91. baseUrl: '/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. it('returns data', () => {
  115. return new Promise<void>((resolve, reject) => {
  116. Piano.allowFetchCollection();
  117. const req = request(
  118. {
  119. host: HOST,
  120. port: PORT,
  121. path: '/api/pianos',
  122. method: 'GET',
  123. headers: {
  124. 'Accept': ACCEPT,
  125. 'Accept-Encoding': ACCEPT_ENCODING,
  126. },
  127. },
  128. (res) => {
  129. res.on('error', (err) => {
  130. Piano.revokeFetchCollection();
  131. reject(err);
  132. });
  133. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  134. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  135. let resBuffer = Buffer.from('');
  136. res.on('data', (c) => {
  137. resBuffer = Buffer.concat([resBuffer, c]);
  138. });
  139. res.on('close', () => {
  140. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  141. const resData = JSON.parse(resBufferJson);
  142. expect(resData).toEqual([]);
  143. Piano.revokeFetchCollection();
  144. resolve();
  145. });
  146. },
  147. );
  148. req.on('error', (err) => {
  149. Piano.revokeFetchCollection();
  150. reject(err);
  151. });
  152. req.end();
  153. });
  154. });
  155. });
  156. describe('serving items', () => {
  157. const data = {
  158. id: 1,
  159. brand: 'Yamaha'
  160. };
  161. beforeEach(async () => {
  162. const resourcePath = join(baseDir, 'pianos.jsonl');
  163. await writeFile(resourcePath, JSON.stringify(data));
  164. });
  165. it('returns data', () => {
  166. return new Promise<void>((resolve, reject) => {
  167. Piano.allowFetchItem();
  168. const req = request(
  169. {
  170. host: HOST,
  171. port: PORT,
  172. path: '/api/pianos/1',
  173. method: 'GET',
  174. headers: {
  175. 'Accept': ACCEPT,
  176. 'Accept-Encoding': ACCEPT_ENCODING,
  177. },
  178. },
  179. (res) => {
  180. res.on('error', (err) => {
  181. Piano.revokeFetchItem();
  182. reject(err);
  183. });
  184. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  185. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  186. let resBuffer = Buffer.from('');
  187. res.on('data', (c) => {
  188. resBuffer = Buffer.concat([resBuffer, c]);
  189. });
  190. res.on('close', () => {
  191. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  192. const resData = JSON.parse(resBufferJson);
  193. expect(resData).toEqual(data);
  194. Piano.revokeFetchItem();
  195. resolve();
  196. });
  197. },
  198. );
  199. req.on('error', (err) => {
  200. Piano.revokeFetchItem();
  201. reject(err);
  202. });
  203. req.end();
  204. });
  205. });
  206. it('throws on item not found', () => {
  207. return new Promise<void>((resolve, reject) => {
  208. Piano.allowFetchItem();
  209. const req = request(
  210. {
  211. host: HOST,
  212. port: PORT,
  213. path: '/api/pianos/2',
  214. method: 'GET',
  215. headers: {
  216. 'Accept': ACCEPT,
  217. 'Accept-Encoding': ACCEPT_ENCODING,
  218. },
  219. },
  220. (res) => {
  221. res.on('error', (err) => {
  222. Piano.revokeFetchItem();
  223. reject(err);
  224. });
  225. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  226. Piano.revokeFetchItem();
  227. resolve();
  228. },
  229. );
  230. req.on('error', (err) => {
  231. Piano.revokeFetchItem();
  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. // FIXME ID de/serialization problems
  251. it('returns data', () => {
  252. return new Promise<void>((resolve, reject) => {
  253. Piano.allowCreate();
  254. const req = request(
  255. {
  256. host: HOST,
  257. port: PORT,
  258. path: '/api/pianos',
  259. method: 'POST',
  260. headers: {
  261. 'Accept': ACCEPT,
  262. 'Accept-Encoding': ACCEPT_ENCODING,
  263. 'Content-Type': ACCEPT,
  264. },
  265. },
  266. (res) => {
  267. res.on('error', (err) => {
  268. Piano.revokeCreate();
  269. reject(err);
  270. });
  271. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  272. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  273. let resBuffer = Buffer.from('');
  274. res.on('data', (c) => {
  275. resBuffer = Buffer.concat([resBuffer, c]);
  276. });
  277. res.on('close', () => {
  278. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  279. const resData = JSON.parse(resBufferJson);
  280. expect(resData).toEqual({
  281. ...newData,
  282. id: '2'
  283. });
  284. Piano.revokeCreate();
  285. resolve();
  286. });
  287. },
  288. );
  289. req.on('error', (err) => {
  290. Piano.revokeCreate();
  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. it('returns data', () => {
  311. return new Promise<void>((resolve, reject) => {
  312. Piano.allowPatch();
  313. const req = request(
  314. {
  315. host: HOST,
  316. port: PORT,
  317. path: '/api/pianos/1',
  318. method: 'PATCH',
  319. headers: {
  320. 'Accept': ACCEPT,
  321. 'Accept-Encoding': ACCEPT_ENCODING,
  322. 'Content-Type': ACCEPT,
  323. },
  324. },
  325. (res) => {
  326. res.on('error', (err) => {
  327. Piano.revokePatch();
  328. reject(err);
  329. });
  330. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  331. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  332. let resBuffer = Buffer.from('');
  333. res.on('data', (c) => {
  334. resBuffer = Buffer.concat([resBuffer, c]);
  335. });
  336. res.on('close', () => {
  337. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  338. const resData = JSON.parse(resBufferJson);
  339. expect(resData).toEqual({
  340. ...data,
  341. ...newData,
  342. });
  343. Piano.revokePatch();
  344. resolve();
  345. });
  346. },
  347. );
  348. req.on('error', (err) => {
  349. Piano.revokePatch();
  350. reject(err);
  351. });
  352. req.write(JSON.stringify(newData));
  353. req.end();
  354. });
  355. });
  356. it('throws on item to patch not found', () => {
  357. return new Promise<void>((resolve, reject) => {
  358. Piano.allowPatch();
  359. const req = request(
  360. {
  361. host: HOST,
  362. port: PORT,
  363. path: '/api/pianos/2',
  364. method: 'PATCH',
  365. headers: {
  366. 'Accept': ACCEPT,
  367. 'Accept-Encoding': ACCEPT_ENCODING,
  368. 'Content-Type': ACCEPT,
  369. },
  370. },
  371. (res) => {
  372. res.on('error', (err) => {
  373. Piano.revokePatch();
  374. reject(err);
  375. });
  376. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  377. Piano.revokePatch();
  378. resolve();
  379. },
  380. );
  381. req.on('error', (err) => {
  382. Piano.revokePatch();
  383. reject(err);
  384. });
  385. req.write(JSON.stringify(newData));
  386. req.end();
  387. });
  388. });
  389. });
  390. describe('emplacing items', () => {
  391. const data = {
  392. id: 1,
  393. brand: 'Yamaha'
  394. };
  395. const newData = {
  396. id: 1,
  397. brand: 'K. Kawai'
  398. };
  399. beforeEach(async () => {
  400. const resourcePath = join(baseDir, 'pianos.jsonl');
  401. await writeFile(resourcePath, JSON.stringify(data));
  402. });
  403. // FIXME IDs not properly being de/serialized
  404. it('returns data for replacement', () => {
  405. return new Promise<void>((resolve, reject) => {
  406. Piano.allowEmplace();
  407. const req = request(
  408. {
  409. host: HOST,
  410. port: PORT,
  411. path: '/api/pianos/1',
  412. method: 'PUT',
  413. headers: {
  414. 'Accept': ACCEPT,
  415. 'Accept-Encoding': ACCEPT_ENCODING,
  416. 'Content-Type': ACCEPT,
  417. },
  418. },
  419. (res) => {
  420. res.on('error', (err) => {
  421. Piano.revokeEmplace();
  422. reject(err);
  423. });
  424. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  425. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  426. let resBuffer = Buffer.from('');
  427. res.on('data', (c) => {
  428. resBuffer = Buffer.concat([resBuffer, c]);
  429. });
  430. res.on('close', () => {
  431. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  432. const resData = JSON.parse(resBufferJson);
  433. expect(resData).toEqual(newData);
  434. Piano.revokeEmplace();
  435. resolve();
  436. });
  437. },
  438. );
  439. req.on('error', (err) => {
  440. Piano.revokeEmplace();
  441. reject(err);
  442. });
  443. req.write(JSON.stringify(newData));
  444. req.end();
  445. });
  446. });
  447. it('returns data for creation', () => {
  448. return new Promise<void>((resolve, reject) => {
  449. const id = 2;
  450. Piano.allowEmplace();
  451. const req = request(
  452. {
  453. host: HOST,
  454. port: PORT,
  455. path: `/api/pianos/${id}`,
  456. method: 'PUT',
  457. headers: {
  458. 'Accept': ACCEPT,
  459. 'Accept-Encoding': ACCEPT_ENCODING,
  460. 'Content-Type': ACCEPT,
  461. },
  462. },
  463. (res) => {
  464. res.on('error', (err) => {
  465. Piano.revokeEmplace();
  466. reject(err);
  467. });
  468. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  469. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  470. let resBuffer = Buffer.from('');
  471. res.on('data', (c) => {
  472. resBuffer = Buffer.concat([resBuffer, c]);
  473. });
  474. res.on('close', () => {
  475. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  476. const resData = JSON.parse(resBufferJson);
  477. expect(resData).toEqual({
  478. ...newData,
  479. id,
  480. });
  481. Piano.revokeEmplace();
  482. resolve();
  483. });
  484. },
  485. );
  486. req.on('error', (err) => {
  487. Piano.revokeEmplace();
  488. reject(err);
  489. });
  490. req.write(JSON.stringify({
  491. ...newData,
  492. id,
  493. }));
  494. req.end();
  495. });
  496. });
  497. });
  498. describe('deleting items', () => {
  499. const data = {
  500. id: 1,
  501. brand: 'Yamaha'
  502. };
  503. beforeEach(async () => {
  504. const resourcePath = join(baseDir, 'pianos.jsonl');
  505. await writeFile(resourcePath, JSON.stringify(data));
  506. });
  507. it('returns data', () => {
  508. return new Promise<void>((resolve, reject) => {
  509. Piano.allowDelete();
  510. const req = request(
  511. {
  512. host: HOST,
  513. port: PORT,
  514. path: '/api/pianos/1',
  515. method: 'DELETE',
  516. headers: {
  517. 'Accept': ACCEPT,
  518. 'Accept-Encoding': ACCEPT_ENCODING,
  519. },
  520. },
  521. (res) => {
  522. res.on('error', (err) => {
  523. Piano.revokeDelete();
  524. reject(err);
  525. });
  526. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  527. Piano.revokeDelete();
  528. resolve();
  529. },
  530. );
  531. req.on('error', (err) => {
  532. Piano.revokeDelete();
  533. reject(err);
  534. });
  535. req.end();
  536. });
  537. });
  538. it('throws on item not found', () => {
  539. return new Promise<void>((resolve, reject) => {
  540. Piano.allowDelete();
  541. const req = request(
  542. {
  543. host: HOST,
  544. port: PORT,
  545. path: '/api/pianos/2',
  546. method: 'DELETE',
  547. headers: {
  548. 'Accept': ACCEPT,
  549. 'Accept-Encoding': ACCEPT_ENCODING,
  550. },
  551. },
  552. (res) => {
  553. res.on('error', (err) => {
  554. Piano.revokeDelete();
  555. reject(err);
  556. });
  557. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  558. Piano.revokeDelete();
  559. resolve();
  560. },
  561. );
  562. req.on('error', (err) => {
  563. Piano.revokeDelete();
  564. reject(err);
  565. });
  566. req.end();
  567. });
  568. });
  569. });
  570. });