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.

765 regels
16 KiB

  1. import {
  2. beforeAll,
  3. afterAll,
  4. afterEach,
  5. beforeEach,
  6. describe,
  7. expect,
  8. it, vi,
  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 {request} from 'http';
  22. import {constants} from 'http2';
  23. import {Backend, dataSources} from '../../../src/backend';
  24. import { application, resource, validation as v, Resource } from '../../../src/common';
  25. import { autoIncrement } from '../../fixtures';
  26. import {createTestClient, TestClient} from '../../utils';
  27. import {DataSource} from '../../../src/backend/data-source';
  28. const PORT = 3001;
  29. const HOST = '127.0.0.1';
  30. const BASE_PATH = '/api';
  31. const ACCEPT = 'application/json';
  32. const ACCEPT_LANGUAGE = 'en';
  33. const ACCEPT_CHARSET = 'utf-8';
  34. const CONTENT_TYPE_CHARSET = 'utf-8';
  35. const CONTENT_TYPE = ACCEPT;
  36. class DummyDataSource implements DataSource {
  37. private resource?: { dataSource?: unknown };
  38. create(): Promise<never> {
  39. throw new Error();
  40. }
  41. delete(): Promise<never> {
  42. throw new Error();
  43. }
  44. emplace(): Promise<never> {
  45. throw new Error();
  46. }
  47. getById(): Promise<never> {
  48. throw new Error();
  49. }
  50. newId(): Promise<never> {
  51. throw new Error();
  52. }
  53. getMultiple(): Promise<never> {
  54. throw new Error();
  55. }
  56. getSingle(): Promise<never> {
  57. throw new Error();
  58. }
  59. getTotalCount(): Promise<never> {
  60. throw new Error();
  61. }
  62. async initialize(): Promise<void> {}
  63. patch(): Promise<never> {
  64. throw new Error();
  65. }
  66. prepareResource(rr: unknown) {
  67. this.resource = rr as unknown as { dataSource: DummyDataSource };
  68. this.resource.dataSource = this;
  69. }
  70. }
  71. describe('error handling', () => {
  72. let client: TestClient;
  73. beforeEach(() => {
  74. client = createTestClient({
  75. host: HOST,
  76. port: PORT,
  77. })
  78. .acceptMediaType(ACCEPT)
  79. .acceptLanguage(ACCEPT_LANGUAGE)
  80. .acceptCharset(ACCEPT_CHARSET)
  81. .contentType(CONTENT_TYPE)
  82. .contentCharset(CONTENT_TYPE_CHARSET);
  83. });
  84. describe('on internal errors', () => {
  85. let baseDir: string;
  86. beforeAll(async () => {
  87. try {
  88. baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
  89. } catch {
  90. // noop
  91. }
  92. });
  93. afterAll(async () => {
  94. try {
  95. await rm(baseDir, {
  96. recursive: true,
  97. });
  98. } catch {
  99. // noop
  100. }
  101. });
  102. let Piano: Resource;
  103. beforeEach(() => {
  104. Piano = resource(v.object(
  105. {
  106. brand: v.string()
  107. },
  108. v.never()
  109. ))
  110. .name('Piano' as const)
  111. .route('pianos' as const)
  112. .id('id' as const, {
  113. generationStrategy: autoIncrement,
  114. serialize: (id) => id?.toString() ?? '0',
  115. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  116. schema: v.number(),
  117. });
  118. });
  119. let dataSource: DataSource;
  120. let backend: Backend;
  121. let server: ReturnType<Backend['createHttpServer']>;
  122. beforeEach(() => {
  123. const app = application({
  124. name: 'piano-service',
  125. })
  126. .resource(Piano);
  127. dataSource = new DummyDataSource();
  128. backend = app.createBackend({
  129. dataSource,
  130. });
  131. server = backend.createHttpServer({
  132. basePath: BASE_PATH
  133. });
  134. return new Promise((resolve, reject) => {
  135. server.on('error', (err) => {
  136. reject(err);
  137. });
  138. server.on('listening', () => {
  139. resolve();
  140. });
  141. server.listen({
  142. port: PORT
  143. });
  144. });
  145. });
  146. afterEach(() => new Promise((resolve, reject) => {
  147. server.close((err) => {
  148. if (err) {
  149. reject(err);
  150. }
  151. resolve();
  152. });
  153. }));
  154. describe.skip('serving collections', () => {
  155. beforeEach(() => {
  156. Piano.canFetchCollection();
  157. return new Promise((resolve) => {
  158. setTimeout(() => {
  159. resolve();
  160. });
  161. });
  162. });
  163. afterEach(() => {
  164. Piano.canFetchCollection(false);
  165. });
  166. it('throws on query', async () => {
  167. const [res] = await client({
  168. method: 'GET',
  169. path: `${BASE_PATH}/pianos`,
  170. });
  171. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  172. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
  173. });
  174. it('throws on HEAD method', async () => {
  175. const [res] = await client({
  176. method: 'HEAD',
  177. path: `${BASE_PATH}/pianos`,
  178. });
  179. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  180. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
  181. });
  182. });
  183. describe('serving items', () => {
  184. const data = {
  185. id: 1,
  186. brand: 'Yamaha'
  187. };
  188. beforeEach(async () => {
  189. const resourcePath = join(baseDir, 'pianos.jsonl');
  190. await writeFile(resourcePath, JSON.stringify(data));
  191. });
  192. beforeEach(() => {
  193. Piano.canFetchItem();
  194. return new Promise((resolve) => {
  195. setTimeout(() => {
  196. resolve();
  197. });
  198. });
  199. });
  200. afterEach(() => {
  201. Piano.canFetchItem(false);
  202. });
  203. it('throws on query', async () => {
  204. const [res] = await client({
  205. method: 'GET',
  206. path: `${BASE_PATH}/pianos/2`,
  207. });
  208. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  209. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
  210. });
  211. it('throws on HEAD method', async () => {
  212. const [res] = await client({
  213. method: 'HEAD',
  214. path: `${BASE_PATH}/pianos/2`,
  215. });
  216. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  217. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
  218. });
  219. it('throws on item not found', async () => {
  220. const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
  221. getById.mockResolvedValueOnce(null as never);
  222. const [res] = await client({
  223. method: 'GET',
  224. path: `${BASE_PATH}/pianos/2`,
  225. });
  226. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  227. });
  228. it('throws on item not found on HEAD method', async () => {
  229. const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
  230. getById.mockResolvedValueOnce(null as never);
  231. const [res] = await client({
  232. method: 'HEAD',
  233. path: `${BASE_PATH}/pianos/2`,
  234. });
  235. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  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('throws on error assigning ID', async () => {
  257. const [res] = await client({
  258. method: 'POST',
  259. path: `${BASE_PATH}/pianos`,
  260. body: newData,
  261. });
  262. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  263. expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source');
  264. });
  265. it('throws on error creating resource', async () => {
  266. const getById = vi.spyOn(DummyDataSource.prototype, 'newId');
  267. getById.mockResolvedValueOnce(data.id as never);
  268. const [res] = await client({
  269. method: 'POST',
  270. path: `${BASE_PATH}/pianos`,
  271. body: newData,
  272. });
  273. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  274. expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano');
  275. });
  276. });
  277. describe.skip('patching items', () => {
  278. const data = {
  279. id: 1,
  280. brand: 'Yamaha'
  281. };
  282. const newData = {
  283. brand: 'K. Kawai'
  284. };
  285. beforeEach(async () => {
  286. const resourcePath = join(baseDir, 'pianos.jsonl');
  287. await writeFile(resourcePath, JSON.stringify(data));
  288. });
  289. beforeEach(() => {
  290. Piano.canPatch();
  291. });
  292. afterEach(() => {
  293. Piano.canPatch(false);
  294. });
  295. it('throws on item to patch not found', () => {
  296. return new Promise<void>((resolve, reject) => {
  297. const req = request(
  298. {
  299. host: HOST,
  300. port: PORT,
  301. path: `${BASE_PATH}/pianos/2`,
  302. method: 'PATCH',
  303. headers: {
  304. 'Accept': ACCEPT,
  305. 'Accept-Language': ACCEPT_LANGUAGE,
  306. 'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
  307. },
  308. },
  309. (res) => {
  310. res.on('error', (err) => {
  311. reject(err);
  312. });
  313. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  314. resolve();
  315. },
  316. );
  317. req.on('error', (err) => {
  318. reject(err);
  319. });
  320. req.write(JSON.stringify(newData));
  321. req.end();
  322. });
  323. });
  324. });
  325. describe.skip('emplacing items', () => {
  326. const data = {
  327. id: 1,
  328. brand: 'Yamaha'
  329. };
  330. const newData = {
  331. id: 1,
  332. brand: 'K. Kawai'
  333. };
  334. beforeEach(async () => {
  335. const resourcePath = join(baseDir, 'pianos.jsonl');
  336. await writeFile(resourcePath, JSON.stringify(data));
  337. });
  338. beforeEach(() => {
  339. Piano.canEmplace();
  340. });
  341. afterEach(() => {
  342. Piano.canEmplace(false);
  343. });
  344. });
  345. describe.skip('deleting items', () => {
  346. const data = {
  347. id: 1,
  348. brand: 'Yamaha'
  349. };
  350. beforeEach(async () => {
  351. const resourcePath = join(baseDir, 'pianos.jsonl');
  352. await writeFile(resourcePath, JSON.stringify(data));
  353. });
  354. beforeEach(() => {
  355. Piano.canDelete();
  356. backend.throwsErrorOnDeletingNotFound();
  357. });
  358. afterEach(() => {
  359. Piano.canDelete(false);
  360. backend.throwsErrorOnDeletingNotFound(false);
  361. });
  362. it('throws on item not found', () => {
  363. return new Promise<void>((resolve, reject) => {
  364. const req = request(
  365. {
  366. host: HOST,
  367. port: PORT,
  368. path: `${BASE_PATH}/pianos/2`,
  369. method: 'DELETE',
  370. headers: {
  371. 'Accept': ACCEPT,
  372. 'Accept-Language': ACCEPT_LANGUAGE,
  373. },
  374. },
  375. (res) => {
  376. res.on('error', (err) => {
  377. reject(err);
  378. });
  379. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  380. resolve();
  381. },
  382. );
  383. req.on('error', (err) => {
  384. reject(err);
  385. });
  386. req.end();
  387. });
  388. });
  389. });
  390. });
  391. describe('on data source errors', () => {
  392. let baseDir: string;
  393. beforeAll(async () => {
  394. try {
  395. baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
  396. } catch {
  397. // noop
  398. }
  399. });
  400. afterAll(async () => {
  401. try {
  402. await rm(baseDir, {
  403. recursive: true,
  404. });
  405. } catch {
  406. // noop
  407. }
  408. });
  409. let Piano: Resource;
  410. beforeEach(() => {
  411. Piano = resource(v.object(
  412. {
  413. brand: v.string()
  414. },
  415. v.never()
  416. ))
  417. .name('Piano' as const)
  418. .route('pianos' as const)
  419. .id('id' as const, {
  420. generationStrategy: autoIncrement,
  421. serialize: (id) => id?.toString() ?? '0',
  422. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  423. schema: v.number(),
  424. });
  425. });
  426. let backend: Backend;
  427. let server: ReturnType<Backend['createHttpServer']>;
  428. beforeEach(() => {
  429. const app = application({
  430. name: 'piano-service',
  431. })
  432. .resource(Piano);
  433. backend = app.createBackend({
  434. dataSource: new dataSources.jsonlFile.DataSource(baseDir),
  435. });
  436. server = backend.createHttpServer({
  437. basePath: BASE_PATH
  438. });
  439. return new Promise((resolve, reject) => {
  440. server.on('error', (err) => {
  441. reject(err);
  442. });
  443. server.on('listening', () => {
  444. resolve();
  445. });
  446. server.listen({
  447. port: PORT
  448. });
  449. });
  450. });
  451. afterEach(() => new Promise((resolve, reject) => {
  452. server.close((err) => {
  453. if (err) {
  454. reject(err);
  455. }
  456. resolve();
  457. });
  458. }));
  459. describe.skip('serving collections', () => {
  460. beforeEach(() => {
  461. Piano.canFetchCollection();
  462. return new Promise((resolve) => {
  463. setTimeout(() => {
  464. resolve();
  465. });
  466. });
  467. });
  468. afterEach(() => {
  469. Piano.canFetchCollection(false);
  470. });
  471. });
  472. describe('serving items', () => {
  473. const data = {
  474. id: 1,
  475. brand: 'Yamaha'
  476. };
  477. beforeEach(async () => {
  478. const resourcePath = join(baseDir, 'pianos.jsonl');
  479. await writeFile(resourcePath, JSON.stringify(data));
  480. });
  481. beforeEach(() => {
  482. Piano.canFetchItem();
  483. return new Promise((resolve) => {
  484. setTimeout(() => {
  485. resolve();
  486. });
  487. });
  488. });
  489. afterEach(() => {
  490. Piano.canFetchItem(false);
  491. });
  492. it('throws on item not found', async () => {
  493. const [res] = await client({
  494. method: 'GET',
  495. path: `${BASE_PATH}/pianos/2`,
  496. });
  497. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  498. });
  499. it('throws on item not found on HEAD method', async () => {
  500. const [res] = await client({
  501. method: 'HEAD',
  502. path: `${BASE_PATH}/pianos/2`,
  503. });
  504. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  505. });
  506. });
  507. describe.skip('creating items', () => {
  508. const data = {
  509. id: 1,
  510. brand: 'Yamaha'
  511. };
  512. const newData = {
  513. brand: 'K. Kawai'
  514. };
  515. beforeEach(async () => {
  516. const resourcePath = join(baseDir, 'pianos.jsonl');
  517. await writeFile(resourcePath, JSON.stringify(data));
  518. });
  519. beforeEach(() => {
  520. Piano.canCreate();
  521. });
  522. afterEach(() => {
  523. Piano.canCreate(false);
  524. });
  525. });
  526. describe.skip('patching items', () => {
  527. const data = {
  528. id: 1,
  529. brand: 'Yamaha'
  530. };
  531. const newData = {
  532. brand: 'K. Kawai'
  533. };
  534. beforeEach(async () => {
  535. const resourcePath = join(baseDir, 'pianos.jsonl');
  536. await writeFile(resourcePath, JSON.stringify(data));
  537. });
  538. beforeEach(() => {
  539. Piano.canPatch();
  540. });
  541. afterEach(() => {
  542. Piano.canPatch(false);
  543. });
  544. it('throws on item to patch not found', () => {
  545. return new Promise<void>((resolve, reject) => {
  546. const req = request(
  547. {
  548. host: HOST,
  549. port: PORT,
  550. path: `${BASE_PATH}/pianos/2`,
  551. method: 'PATCH',
  552. headers: {
  553. 'Accept': ACCEPT,
  554. 'Accept-Language': ACCEPT_LANGUAGE,
  555. 'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
  556. },
  557. },
  558. (res) => {
  559. res.on('error', (err) => {
  560. reject(err);
  561. });
  562. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  563. resolve();
  564. },
  565. );
  566. req.on('error', (err) => {
  567. reject(err);
  568. });
  569. req.write(JSON.stringify(newData));
  570. req.end();
  571. });
  572. });
  573. });
  574. describe.skip('emplacing items', () => {
  575. const data = {
  576. id: 1,
  577. brand: 'Yamaha'
  578. };
  579. const newData = {
  580. id: 1,
  581. brand: 'K. Kawai'
  582. };
  583. beforeEach(async () => {
  584. const resourcePath = join(baseDir, 'pianos.jsonl');
  585. await writeFile(resourcePath, JSON.stringify(data));
  586. });
  587. beforeEach(() => {
  588. Piano.canEmplace();
  589. });
  590. afterEach(() => {
  591. Piano.canEmplace(false);
  592. });
  593. });
  594. describe.skip('deleting items', () => {
  595. const data = {
  596. id: 1,
  597. brand: 'Yamaha'
  598. };
  599. beforeEach(async () => {
  600. const resourcePath = join(baseDir, 'pianos.jsonl');
  601. await writeFile(resourcePath, JSON.stringify(data));
  602. });
  603. beforeEach(() => {
  604. Piano.canDelete();
  605. backend.throwsErrorOnDeletingNotFound();
  606. });
  607. afterEach(() => {
  608. Piano.canDelete(false);
  609. backend.throwsErrorOnDeletingNotFound(false);
  610. });
  611. it('throws on item not found', () => {
  612. return new Promise<void>((resolve, reject) => {
  613. const req = request(
  614. {
  615. host: HOST,
  616. port: PORT,
  617. path: `${BASE_PATH}/pianos/2`,
  618. method: 'DELETE',
  619. headers: {
  620. 'Accept': ACCEPT,
  621. 'Accept-Language': ACCEPT_LANGUAGE,
  622. },
  623. },
  624. (res) => {
  625. res.on('error', (err) => {
  626. reject(err);
  627. });
  628. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  629. resolve();
  630. },
  631. );
  632. req.on('error', (err) => {
  633. reject(err);
  634. });
  635. req.end();
  636. });
  637. });
  638. });
  639. });
  640. });