HATEOAS-first backend framework.
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

665 行
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. const backend = app
  91. .createBackend()
  92. .throws404OnDeletingNotFound();
  93. server = backend.createServer({
  94. baseUrl: '/api'
  95. });
  96. return new Promise((resolve, reject) => {
  97. server.on('error', (err) => {
  98. reject(err);
  99. });
  100. server.on('listening', () => {
  101. resolve();
  102. });
  103. server.listen({
  104. port: PORT
  105. });
  106. });
  107. });
  108. afterEach(() => new Promise((resolve, reject) => {
  109. server.close((err) => {
  110. if (err) {
  111. reject(err);
  112. }
  113. resolve();
  114. });
  115. }));
  116. describe('serving collections', () => {
  117. beforeEach(() => {
  118. Piano.canFetchCollection();
  119. });
  120. afterEach(() => {
  121. Piano.canFetchCollection(false);
  122. });
  123. it('returns data', () => {
  124. return new Promise<void>((resolve, reject) => {
  125. const req = request(
  126. {
  127. host: HOST,
  128. port: PORT,
  129. path: '/api/pianos',
  130. method: 'GET',
  131. headers: {
  132. 'Accept': ACCEPT,
  133. 'Accept-Encoding': ACCEPT_ENCODING,
  134. },
  135. },
  136. (res) => {
  137. res.on('error', (err) => {
  138. reject(err);
  139. });
  140. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  141. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  142. let resBuffer = Buffer.from('');
  143. res.on('data', (c) => {
  144. resBuffer = Buffer.concat([resBuffer, c]);
  145. });
  146. res.on('close', () => {
  147. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  148. const resData = JSON.parse(resBufferJson);
  149. expect(resData).toEqual([]);
  150. resolve();
  151. });
  152. },
  153. );
  154. req.on('error', (err) => {
  155. reject(err);
  156. });
  157. req.end();
  158. });
  159. });
  160. });
  161. describe('serving items', () => {
  162. const data = {
  163. id: 1,
  164. brand: 'Yamaha'
  165. };
  166. beforeEach(async () => {
  167. const resourcePath = join(baseDir, 'pianos.jsonl');
  168. await writeFile(resourcePath, JSON.stringify(data));
  169. });
  170. beforeEach(() => {
  171. Piano.canFetchItem();
  172. });
  173. afterEach(() => {
  174. Piano.canFetchItem(false);
  175. });
  176. it('returns data', () => {
  177. return new Promise<void>((resolve, reject) => {
  178. const req = request(
  179. {
  180. host: HOST,
  181. port: PORT,
  182. path: '/api/pianos/1',
  183. method: 'GET',
  184. headers: {
  185. 'Accept': ACCEPT,
  186. 'Accept-Encoding': ACCEPT_ENCODING,
  187. },
  188. },
  189. (res) => {
  190. res.on('error', (err) => {
  191. reject(err);
  192. });
  193. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  194. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  195. let resBuffer = Buffer.from('');
  196. res.on('data', (c) => {
  197. resBuffer = Buffer.concat([resBuffer, c]);
  198. });
  199. res.on('close', () => {
  200. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  201. const resData = JSON.parse(resBufferJson);
  202. expect(resData).toEqual(data);
  203. resolve();
  204. });
  205. },
  206. );
  207. req.on('error', (err) => {
  208. reject(err);
  209. });
  210. req.end();
  211. });
  212. });
  213. it('throws on item not found', () => {
  214. return new Promise<void>((resolve, reject) => {
  215. const req = request(
  216. {
  217. host: HOST,
  218. port: PORT,
  219. path: '/api/pianos/2',
  220. method: 'GET',
  221. headers: {
  222. 'Accept': ACCEPT,
  223. 'Accept-Encoding': ACCEPT_ENCODING,
  224. },
  225. },
  226. (res) => {
  227. res.on('error', (err) => {
  228. Piano.canFetchItem(false);
  229. reject(err);
  230. });
  231. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  232. resolve();
  233. },
  234. );
  235. req.on('error', (err) => {
  236. reject(err);
  237. });
  238. req.end();
  239. });
  240. });
  241. });
  242. describe('creating items', () => {
  243. const data = {
  244. id: 1,
  245. brand: 'Yamaha'
  246. };
  247. const newData = {
  248. brand: 'K. Kawai'
  249. };
  250. beforeEach(async () => {
  251. const resourcePath = join(baseDir, 'pianos.jsonl');
  252. await writeFile(resourcePath, JSON.stringify(data));
  253. });
  254. beforeEach(() => {
  255. Piano.canCreate();
  256. });
  257. afterEach(() => {
  258. Piano.canCreate(false);
  259. });
  260. // FIXME ID de/serialization problems
  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. // FIXME IDs not properly being de/serialized
  414. it('returns data for replacement', () => {
  415. return new Promise<void>((resolve, reject) => {
  416. const req = request(
  417. {
  418. host: HOST,
  419. port: PORT,
  420. path: `/api/pianos/${newData.id}`,
  421. method: 'PUT',
  422. headers: {
  423. 'Accept': ACCEPT,
  424. 'Accept-Encoding': ACCEPT_ENCODING,
  425. 'Content-Type': ACCEPT,
  426. },
  427. },
  428. (res) => {
  429. res.on('error', (err) => {
  430. reject(err);
  431. });
  432. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  433. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  434. let resBuffer = Buffer.from('');
  435. res.on('data', (c) => {
  436. resBuffer = Buffer.concat([resBuffer, c]);
  437. });
  438. res.on('close', () => {
  439. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  440. const resData = JSON.parse(resBufferJson);
  441. expect(resData).toEqual(newData);
  442. resolve();
  443. });
  444. },
  445. );
  446. req.on('error', (err) => {
  447. reject(err);
  448. });
  449. req.write(JSON.stringify(newData));
  450. req.end();
  451. });
  452. });
  453. it('returns data for creation', () => {
  454. return new Promise<void>((resolve, reject) => {
  455. const id = 2;
  456. const req = request(
  457. {
  458. host: HOST,
  459. port: PORT,
  460. path: `/api/pianos/${id}`,
  461. method: 'PUT',
  462. headers: {
  463. 'Accept': ACCEPT,
  464. 'Accept-Encoding': ACCEPT_ENCODING,
  465. 'Content-Type': ACCEPT,
  466. },
  467. },
  468. (res) => {
  469. res.on('error', (err) => {
  470. reject(err);
  471. });
  472. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  473. expect(res.headers).toHaveProperty('content-type', ACCEPT);
  474. let resBuffer = Buffer.from('');
  475. res.on('data', (c) => {
  476. resBuffer = Buffer.concat([resBuffer, c]);
  477. });
  478. res.on('close', () => {
  479. const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
  480. const resData = JSON.parse(resBufferJson);
  481. expect(resData).toEqual({
  482. ...newData,
  483. id,
  484. });
  485. resolve();
  486. });
  487. },
  488. );
  489. req.on('error', (err) => {
  490. reject(err);
  491. });
  492. req.write(JSON.stringify({
  493. ...newData,
  494. id,
  495. }));
  496. req.end();
  497. });
  498. });
  499. });
  500. describe('deleting items', () => {
  501. const data = {
  502. id: 1,
  503. brand: 'Yamaha'
  504. };
  505. beforeEach(async () => {
  506. const resourcePath = join(baseDir, 'pianos.jsonl');
  507. await writeFile(resourcePath, JSON.stringify(data));
  508. });
  509. beforeEach(() => {
  510. Piano.canDelete();
  511. });
  512. afterEach(() => {
  513. Piano.canDelete(false);
  514. });
  515. it('returns data', () => {
  516. return new Promise<void>((resolve, reject) => {
  517. const req = request(
  518. {
  519. host: HOST,
  520. port: PORT,
  521. path: `/api/pianos/${data.id}`,
  522. method: 'DELETE',
  523. headers: {
  524. 'Accept': ACCEPT,
  525. 'Accept-Encoding': ACCEPT_ENCODING,
  526. },
  527. },
  528. (res) => {
  529. res.on('error', (err) => {
  530. reject(err);
  531. });
  532. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  533. resolve();
  534. },
  535. );
  536. req.on('error', (err) => {
  537. reject(err);
  538. });
  539. req.end();
  540. });
  541. });
  542. it('throws on item not found', () => {
  543. return new Promise<void>((resolve, reject) => {
  544. const req = request(
  545. {
  546. host: HOST,
  547. port: PORT,
  548. path: '/api/pianos/2',
  549. method: 'DELETE',
  550. headers: {
  551. 'Accept': ACCEPT,
  552. 'Accept-Encoding': ACCEPT_ENCODING,
  553. },
  554. },
  555. (res) => {
  556. res.on('error', (err) => {
  557. reject(err);
  558. });
  559. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  560. resolve();
  561. },
  562. );
  563. req.on('error', (err) => {
  564. reject(err);
  565. });
  566. req.end();
  567. });
  568. });
  569. });
  570. });