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.

535 lines
14 KiB

  1. import {
  2. beforeAll,
  3. afterAll,
  4. afterEach,
  5. beforeEach,
  6. describe,
  7. expect,
  8. it,
  9. vi,
  10. } from 'vitest';
  11. import {constants} from 'http2';
  12. import {Backend, DataSource} from '@modal-sh/yasumi/backend';
  13. import {application, resource, validation as v, Resource, Application, Delta} from '@modal-sh/yasumi';
  14. import {
  15. createTestClient,
  16. TestClient,
  17. DummyDataSource,
  18. DummyError,
  19. TEST_LANGUAGE,
  20. dummyGenerationStrategy,
  21. } from '../utils';
  22. import {httpExtender, HttpServer} from '../../src';
  23. const PORT = 3001;
  24. const HOST = '127.0.0.1';
  25. const BASE_PATH = '/api';
  26. const ACCEPT = 'application/json';
  27. const ACCEPT_LANGUAGE = 'en';
  28. const ACCEPT_CHARSET = 'utf-8';
  29. const CONTENT_TYPE_CHARSET = 'utf-8';
  30. const CONTENT_TYPE = ACCEPT;
  31. const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano');
  32. describe('error handling', () => {
  33. let Piano: Resource;
  34. let app: Application;
  35. let dataSource: DataSource;
  36. let backend: Backend;
  37. let server: HttpServer;
  38. let client: TestClient;
  39. beforeAll(() => {
  40. Piano = resource(v.object(
  41. {
  42. brand: v.string()
  43. },
  44. v.never()
  45. ))
  46. .name('Piano' as const)
  47. .route('pianos' as const)
  48. .id('id' as const, {
  49. generationStrategy: dummyGenerationStrategy,
  50. serialize: (id) => id?.toString() ?? '0',
  51. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  52. schema: v.number(),
  53. });
  54. app = application({
  55. name: 'piano-service',
  56. })
  57. .language(TEST_LANGUAGE)
  58. .resource(Piano);
  59. dataSource = new DummyDataSource();
  60. backend = app
  61. .createBackend({
  62. dataSource,
  63. })
  64. .use(httpExtender);
  65. server = backend.createServer('http', {
  66. basePath: BASE_PATH
  67. });
  68. client = createTestClient({
  69. host: HOST,
  70. port: PORT,
  71. })
  72. .acceptMediaType(ACCEPT)
  73. .acceptLanguage(ACCEPT_LANGUAGE)
  74. .acceptCharset(ACCEPT_CHARSET)
  75. .contentType(CONTENT_TYPE)
  76. .contentCharset(CONTENT_TYPE_CHARSET);
  77. return new Promise((resolve, reject) => {
  78. server.on('error', (err) => {
  79. reject(err);
  80. });
  81. server.on('listening', () => {
  82. resolve();
  83. });
  84. server.listen({
  85. port: PORT
  86. });
  87. });
  88. });
  89. afterAll(() => new Promise<void>((resolve, reject) => {
  90. server.close((err) => {
  91. if (err) {
  92. reject(err);
  93. }
  94. resolve();
  95. });
  96. }));
  97. describe('serving collections', () => {
  98. beforeEach(() => {
  99. Piano.canFetchCollection();
  100. });
  101. afterEach(() => {
  102. Piano.canFetchCollection(false);
  103. });
  104. it('throws on query', async () => {
  105. vi
  106. .spyOn(DummyDataSource.prototype, 'getMultiple')
  107. .mockImplementationOnce(() => { throw new DummyError() });
  108. const [res] = await client({
  109. method: 'GET',
  110. path: `${BASE_PATH}/pianos`,
  111. });
  112. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  113. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection));
  114. });
  115. it('throws on HEAD method', async () => {
  116. vi
  117. .spyOn(DummyDataSource.prototype, 'getMultiple')
  118. .mockImplementationOnce(() => { throw new DummyError() });
  119. const [res] = await client({
  120. method: 'HEAD',
  121. path: `${BASE_PATH}/pianos`,
  122. });
  123. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  124. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection));
  125. });
  126. });
  127. describe('serving items', () => {
  128. beforeEach(() => {
  129. Piano.canFetchItem();
  130. });
  131. afterEach(() => {
  132. Piano.canFetchItem(false);
  133. });
  134. it('throws on query', async () => {
  135. vi
  136. .spyOn(DummyDataSource.prototype, 'getById')
  137. .mockImplementationOnce(() => { throw new DummyError() });
  138. const [res] = await client({
  139. method: 'GET',
  140. path: `${BASE_PATH}/pianos/2`,
  141. });
  142. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  143. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
  144. });
  145. it('throws on HEAD method', async () => {
  146. vi
  147. .spyOn(DummyDataSource.prototype, 'getById')
  148. .mockImplementationOnce(() => { throw new DummyError() });
  149. const [res] = await client({
  150. method: 'HEAD',
  151. path: `${BASE_PATH}/pianos/2`,
  152. });
  153. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  154. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
  155. });
  156. it('throws on item not found', async () => {
  157. vi
  158. .spyOn(DummyDataSource.prototype, 'getById')
  159. .mockResolvedValueOnce(null as never);
  160. const [res] = await client({
  161. method: 'GET',
  162. path: `${BASE_PATH}/pianos/2`,
  163. });
  164. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  165. });
  166. it('throws on item not found on HEAD method', async () => {
  167. const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
  168. getById.mockResolvedValueOnce(null as never);
  169. const [res] = await client({
  170. method: 'HEAD',
  171. path: `${BASE_PATH}/pianos/2`,
  172. });
  173. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  174. });
  175. });
  176. describe('creating items', () => {
  177. const newData = {
  178. brand: 'K. Kawai'
  179. };
  180. const existingResource = {
  181. ...newData,
  182. id: 1,
  183. };
  184. beforeEach(() => {
  185. Piano.canCreate();
  186. });
  187. afterEach(() => {
  188. Piano.canCreate(false);
  189. });
  190. it('throws on error assigning ID', async () => {
  191. vi
  192. .spyOn(DummyDataSource.prototype, 'newId')
  193. .mockImplementationOnce(() => { throw new DummyError() });
  194. const [res] = await client({
  195. method: 'POST',
  196. path: `${BASE_PATH}/pianos`,
  197. body: newData,
  198. });
  199. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  200. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToAssignIdFromResourceDataSource));
  201. });
  202. it('throws on error creating resource', async () => {
  203. vi
  204. .spyOn(DummyDataSource.prototype, 'newId')
  205. .mockResolvedValueOnce(existingResource.id as never);
  206. vi
  207. .spyOn(DummyDataSource.prototype, 'create')
  208. .mockImplementationOnce(() => { throw new DummyError() });
  209. const [res] = await client({
  210. method: 'POST',
  211. path: `${BASE_PATH}/pianos`,
  212. body: newData,
  213. });
  214. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  215. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToCreateResource));
  216. });
  217. });
  218. describe('patching items', () => {
  219. const existingResource = {
  220. id: 1,
  221. brand: 'Yamaha'
  222. };
  223. const newData = {
  224. brand: 'K. Kawai'
  225. };
  226. // TODO add more tests
  227. it('throws on unable to fetch existing item', async () => {
  228. Piano.canPatch();
  229. vi
  230. .spyOn(DummyDataSource.prototype, 'getById')
  231. .mockImplementationOnce(() => { throw new DummyError() });
  232. const [res] = await client({
  233. method: 'PATCH',
  234. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  235. body: newData,
  236. headers: {
  237. 'content-type': 'application/merge-patch+json',
  238. },
  239. });
  240. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  241. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
  242. Piano.canPatch(false);
  243. });
  244. it('throws on item to patch not found', async () => {
  245. Piano.canPatch();
  246. vi
  247. .spyOn(DummyDataSource.prototype, 'getById')
  248. .mockResolvedValueOnce(null as never);
  249. const [res] = await client({
  250. method: 'PATCH',
  251. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  252. body: newData,
  253. headers: {
  254. 'content-type': 'application/merge-patch+json',
  255. },
  256. });
  257. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  258. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.patchNonExistingResource));
  259. Piano.canPatch(false);
  260. });
  261. describe('on merge patch', () => {
  262. const newMergeData = {
  263. brand: 'K. Kawai'
  264. };
  265. beforeEach(() => {
  266. Piano.canPatch(['merge']);
  267. });
  268. afterEach(() => {
  269. Piano.canPatch(false);
  270. });
  271. it('throws on attempting to request a delta patch', async () => {
  272. const [res] = await client({
  273. method: 'PATCH',
  274. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  275. body: newMergeData,
  276. headers: {
  277. 'content-type': 'application/json-patch+json',
  278. },
  279. });
  280. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE);
  281. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType));
  282. });
  283. });
  284. describe('on delta patch', () => {
  285. beforeEach(() => {
  286. Piano.canPatch(['delta']);
  287. });
  288. afterEach(() => {
  289. Piano.canPatch(false);
  290. });
  291. it('throws on attempting to request a merge patch', async () => {
  292. const [res] = await client({
  293. method: 'PATCH',
  294. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  295. body: { brand: 'Hello' },
  296. headers: {
  297. 'content-type': 'application/merge-patch+json',
  298. },
  299. });
  300. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE);
  301. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType));
  302. });
  303. it('throws on operating with a delta to an attribute outside the schema', async () => {
  304. const [res] = await client({
  305. method: 'PATCH',
  306. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  307. body: [
  308. {
  309. op: 'replace',
  310. path: 'brandUnknown',
  311. value: 'K. Kawai',
  312. },
  313. ] satisfies Delta[],
  314. headers: {
  315. 'content-type': 'application/json-patch+json',
  316. },
  317. });
  318. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
  319. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
  320. });
  321. it('throws on operating a delta with mismatched value type', async () => {
  322. const [res] = await client({
  323. method: 'PATCH',
  324. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  325. body: [
  326. {
  327. op: 'replace',
  328. path: 'brand',
  329. value: 5,
  330. },
  331. ] satisfies Delta[],
  332. headers: {
  333. 'content-type': 'application/json-patch+json',
  334. },
  335. });
  336. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
  337. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
  338. });
  339. it('throws on performing an invalid delta', async () => {
  340. const [res] = await client({
  341. method: 'PATCH',
  342. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  343. body: [
  344. {
  345. op: 'add',
  346. path: 'brand',
  347. value: 5,
  348. },
  349. ] satisfies Delta[],
  350. headers: {
  351. 'content-type': 'application/json-patch+json',
  352. },
  353. });
  354. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
  355. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
  356. });
  357. });
  358. });
  359. describe('emplacing items', () => {
  360. const existingResource = {
  361. id: 1,
  362. brand: 'Yamaha'
  363. };
  364. beforeEach(() => {
  365. vi
  366. .spyOn(DummyDataSource.prototype, 'getById')
  367. .mockResolvedValueOnce(existingResource as never);
  368. });
  369. const newData = {
  370. id: 1,
  371. brand: 'K. Kawai'
  372. };
  373. beforeEach(() => {
  374. Piano.canEmplace();
  375. });
  376. afterEach(() => {
  377. Piano.canEmplace(false);
  378. });
  379. it('throws on unable to emplace', async () => {
  380. vi
  381. .spyOn(DummyDataSource.prototype, 'emplace')
  382. .mockImplementationOnce(() => { throw new DummyError() });
  383. const [res] = await client({
  384. method: 'PUT',
  385. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  386. body: newData,
  387. });
  388. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  389. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToEmplaceResource));
  390. });
  391. });
  392. describe('deleting items', () => {
  393. const existingResource = {
  394. id: 1,
  395. brand: 'Yamaha'
  396. };
  397. beforeEach(() => {
  398. Piano.canDelete();
  399. backend.throwsErrorOnDeletingNotFound();
  400. });
  401. afterEach(() => {
  402. Piano.canDelete(false);
  403. backend.throwsErrorOnDeletingNotFound(false);
  404. });
  405. it('throws on unable to check if item exists', async () => {
  406. vi
  407. .spyOn(DummyDataSource.prototype, 'getById')
  408. .mockImplementationOnce(() => { throw new DummyError() });
  409. const [res] = await client({
  410. method: 'DELETE',
  411. path: `${BASE_PATH}/pianos/2`,
  412. });
  413. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  414. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
  415. });
  416. it('throws on item not found', async () => {
  417. vi
  418. .spyOn(DummyDataSource.prototype, 'getById')
  419. .mockResolvedValueOnce(null as never);
  420. const [res] = await client({
  421. method: 'DELETE',
  422. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  423. });
  424. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  425. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.deleteNonExistingResource));
  426. });
  427. it('throws on unable to delete item', async () => {
  428. vi
  429. .spyOn(DummyDataSource.prototype, 'getById')
  430. .mockResolvedValueOnce(existingResource as never);
  431. vi
  432. .spyOn(DummyDataSource.prototype, 'delete')
  433. .mockImplementationOnce(() => { throw new DummyError() });
  434. const [res] = await client({
  435. method: 'DELETE',
  436. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  437. });
  438. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  439. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToDeleteResource));
  440. });
  441. });
  442. });