HATEOAS-first backend framework.
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

605 lines
16 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 {
  14. application,
  15. resource,
  16. validation as v,
  17. Resource,
  18. Application,
  19. } from '@modal-sh/yasumi';
  20. import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../utils';
  21. import {httpExtender, HttpServer} from '../../src';
  22. const PORT = 3000;
  23. const HOST = '127.0.0.1';
  24. const BASE_PATH = '/api';
  25. const ACCEPT = 'application/json';
  26. const ACCEPT_LANGUAGE = 'en';
  27. const ACCEPT_CHARSET = 'utf-8';
  28. const CONTENT_TYPE_CHARSET = 'utf-8';
  29. const CONTENT_TYPE = ACCEPT;
  30. const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano');
  31. describe('happy path', () => {
  32. let Piano: Resource;
  33. let app: Application;
  34. let dataSource: DataSource;
  35. let backend: Backend;
  36. let server: HttpServer;
  37. let client: TestClient;
  38. beforeAll(() => {
  39. Piano = resource(v.object(
  40. {
  41. brand: v.string()
  42. },
  43. v.never()
  44. ))
  45. .name('Piano' as const)
  46. .route('pianos' as const)
  47. .id('id' as const, {
  48. generationStrategy: dummyGenerationStrategy,
  49. serialize: (id) => id?.toString() ?? '0',
  50. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  51. schema: v.number(),
  52. });
  53. app = application({
  54. name: 'piano-service',
  55. })
  56. .language(TEST_LANGUAGE)
  57. .resource(Piano);
  58. dataSource = new DummyDataSource();
  59. backend = app
  60. .createBackend({
  61. dataSource,
  62. })
  63. .use(httpExtender);
  64. server = backend.createServer('http', {
  65. basePath: BASE_PATH
  66. });
  67. client = createTestClient({
  68. host: HOST,
  69. port: PORT,
  70. })
  71. .acceptMediaType(ACCEPT)
  72. .acceptLanguage(ACCEPT_LANGUAGE)
  73. .acceptCharset(ACCEPT_CHARSET)
  74. .contentType(CONTENT_TYPE)
  75. .contentCharset(CONTENT_TYPE_CHARSET);
  76. return new Promise((resolve, reject) => {
  77. server.on('error', (err) => {
  78. reject(err);
  79. });
  80. server.on('listening', () => {
  81. resolve();
  82. });
  83. server.listen({
  84. port: PORT
  85. });
  86. });
  87. });
  88. afterAll(() => new Promise<void>((resolve, reject) => {
  89. server.close((err) => {
  90. if (err) {
  91. reject(err);
  92. }
  93. resolve();
  94. });
  95. }));
  96. describe('querying collections', () => {
  97. beforeEach(() => {
  98. vi
  99. .spyOn(DummyDataSource.prototype, 'getMultiple')
  100. .mockResolvedValueOnce([] as never);
  101. });
  102. beforeEach(() => {
  103. Piano.canFetchCollection();
  104. });
  105. afterEach(() => {
  106. Piano.canFetchCollection(false);
  107. });
  108. it('returns data', async () => {
  109. // const [res, resData] = await client({
  110. // method: 'QUERY',
  111. // path: `${BASE_PATH}/pianos`,
  112. // headers: {
  113. // 'content-type': 'application/x-www-form-urlencoded',
  114. // },
  115. // body: 'foo=bar',
  116. // });
  117. const [res, resData] = await client({
  118. method: 'POST',
  119. path: `${BASE_PATH}/pianos`,
  120. headers: {
  121. 'content-type': 'application/x-www-form-urlencoded',
  122. 'x-original-method': 'QUERY',
  123. },
  124. body: 'foo=bar',
  125. });
  126. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  127. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  128. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  129. if (typeof resData === 'undefined') {
  130. expect.fail('Response body must be defined.');
  131. return;
  132. }
  133. expect(resData).toEqual([]);
  134. });
  135. });
  136. describe('serving collections', () => {
  137. beforeEach(() => {
  138. vi
  139. .spyOn(DummyDataSource.prototype, 'getMultiple')
  140. .mockResolvedValueOnce([] as never);
  141. });
  142. beforeEach(() => {
  143. Piano.canFetchCollection();
  144. });
  145. afterEach(() => {
  146. Piano.canFetchCollection(false);
  147. });
  148. it('returns data', async () => {
  149. const [res, resData] = await client({
  150. method: 'GET',
  151. path: `${BASE_PATH}/pianos`,
  152. });
  153. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  154. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  155. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  156. if (typeof resData === 'undefined') {
  157. expect.fail('Response body must be defined.');
  158. return;
  159. }
  160. expect(resData).toEqual([]);
  161. });
  162. it('returns data on HEAD method', async () => {
  163. const [res] = await client({
  164. method: 'HEAD',
  165. path: `${BASE_PATH}/pianos`,
  166. });
  167. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  168. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
  169. });
  170. it('returns options', async () => {
  171. const [res] = await client({
  172. method: 'OPTIONS',
  173. path: `${BASE_PATH}/pianos`,
  174. });
  175. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  176. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  177. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  178. expect(allowedMethods).toContain('GET');
  179. expect(allowedMethods).toContain('HEAD');
  180. });
  181. });
  182. describe('serving items', () => {
  183. const existingResource = {
  184. id: 1,
  185. brand: 'Yamaha'
  186. };
  187. beforeEach(() => {
  188. vi
  189. .spyOn(DummyDataSource.prototype, 'getById')
  190. .mockResolvedValueOnce(existingResource as never);
  191. });
  192. beforeEach(() => {
  193. Piano.canFetchItem();
  194. });
  195. afterEach(() => {
  196. Piano.canFetchItem(false);
  197. });
  198. it('returns data', async () => {
  199. const [res, resData] = await client({
  200. method: 'GET',
  201. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  202. });
  203. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  204. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
  205. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  206. if (typeof resData === 'undefined') {
  207. expect.fail('Response body must be defined.');
  208. return;
  209. }
  210. expect(resData).toEqual(existingResource);
  211. });
  212. it('returns data on HEAD method', async () => {
  213. const [res] = await client({
  214. method: 'HEAD',
  215. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  216. });
  217. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  218. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
  219. });
  220. it('returns options', async () => {
  221. const [res] = await client({
  222. method: 'OPTIONS',
  223. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  224. });
  225. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  226. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  227. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  228. expect(allowedMethods).toContain('GET');
  229. expect(allowedMethods).toContain('HEAD');
  230. });
  231. });
  232. describe('creating items', () => {
  233. const newResourceData = {
  234. brand: 'K. Kawai'
  235. };
  236. const responseData = {
  237. id: 2,
  238. ...newResourceData,
  239. };
  240. beforeEach(() => {
  241. vi
  242. .spyOn(DummyDataSource.prototype, 'newId')
  243. .mockResolvedValueOnce(responseData.id as never);
  244. });
  245. beforeEach(() => {
  246. vi
  247. .spyOn(DummyDataSource.prototype, 'create')
  248. .mockResolvedValueOnce(responseData as never);
  249. });
  250. beforeEach(() => {
  251. Piano.canCreate();
  252. });
  253. afterEach(() => {
  254. Piano.canCreate(false);
  255. });
  256. it('returns data', async () => {
  257. const [res, resData] = await client({
  258. path: `${BASE_PATH}/pianos`,
  259. method: 'POST',
  260. body: newResourceData,
  261. });
  262. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  263. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
  264. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  265. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`);
  266. if (typeof resData === 'undefined') {
  267. expect.fail('Response body must be defined.');
  268. return;
  269. }
  270. expect(resData).toEqual({
  271. ...newResourceData,
  272. id: 2
  273. });
  274. });
  275. it('returns options', async () => {
  276. const [res] = await client({
  277. method: 'OPTIONS',
  278. path: `${BASE_PATH}/pianos`,
  279. });
  280. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  281. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  282. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  283. expect(allowedMethods).toContain('POST');
  284. });
  285. });
  286. describe('patching items', () => {
  287. const existingResource = {
  288. id: 1,
  289. brand: 'Yamaha'
  290. };
  291. const patchData = {
  292. brand: 'K. Kawai'
  293. };
  294. beforeEach(() => {
  295. vi
  296. .spyOn(DummyDataSource.prototype, 'getById')
  297. .mockResolvedValueOnce(existingResource as never);
  298. });
  299. beforeEach(() => {
  300. vi
  301. .spyOn(DummyDataSource.prototype, 'patch')
  302. .mockResolvedValueOnce({
  303. ...existingResource,
  304. ...patchData,
  305. } as never);
  306. });
  307. beforeEach(() => {
  308. Piano.canPatch();
  309. });
  310. afterEach(() => {
  311. Piano.canPatch(false);
  312. });
  313. it('returns options', async () => {
  314. const [res] = await client({
  315. method: 'OPTIONS',
  316. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  317. });
  318. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  319. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  320. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  321. expect(allowedMethods).toContain('PATCH');
  322. const acceptPatch = res.headers['accept-patch']?.split(',').map((s) => s.trim()) ?? [];
  323. expect(acceptPatch).toContain('application/json-patch+json');
  324. expect(acceptPatch).toContain('application/merge-patch+json');
  325. });
  326. describe('on merge', () => {
  327. beforeEach(() => {
  328. Piano.canPatch(false).canPatch(['merge']);
  329. });
  330. it('returns data', async () => {
  331. const [res, resData] = await client({
  332. method: 'PATCH',
  333. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  334. body: patchData,
  335. headers: {
  336. 'content-type': 'application/merge-patch+json',
  337. },
  338. });
  339. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  340. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
  341. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  342. if (typeof resData === 'undefined') {
  343. expect.fail('Response body must be defined.');
  344. return;
  345. }
  346. expect(resData).toEqual({
  347. ...existingResource,
  348. ...patchData,
  349. });
  350. });
  351. });
  352. describe('on delta', () => {
  353. beforeEach(() => {
  354. Piano.canPatch(false).canPatch(['delta']);
  355. });
  356. it('returns data', async () => {
  357. const [res, resData] = await client({
  358. method: 'PATCH',
  359. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  360. body: [
  361. {
  362. op: 'replace',
  363. path: 'brand',
  364. value: patchData.brand,
  365. },
  366. ],
  367. headers: {
  368. 'content-type': 'application/json-patch+json',
  369. },
  370. });
  371. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  372. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
  373. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  374. if (typeof resData === 'undefined') {
  375. expect.fail('Response body must be defined.');
  376. return;
  377. }
  378. expect(resData).toEqual({
  379. ...existingResource,
  380. ...patchData,
  381. });
  382. });
  383. });
  384. });
  385. describe('emplacing items', () => {
  386. const existingResource = {
  387. id: 1,
  388. brand: 'Yamaha'
  389. };
  390. const emplaceResourceData = {
  391. id: 1,
  392. brand: 'K. Kawai'
  393. };
  394. beforeEach(() => {
  395. Piano.canEmplace();
  396. });
  397. afterEach(() => {
  398. Piano.canEmplace(false);
  399. });
  400. it('returns data for replacement', async () => {
  401. vi
  402. .spyOn(DummyDataSource.prototype, 'emplace')
  403. .mockResolvedValueOnce([{
  404. ...existingResource,
  405. ...emplaceResourceData,
  406. }, false] as never);
  407. const [res, resData] = await client({
  408. method: 'PUT',
  409. path: `${BASE_PATH}/pianos/${emplaceResourceData.id}`,
  410. body: emplaceResourceData,
  411. });
  412. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
  413. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceReplaced));
  414. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  415. if (typeof resData === 'undefined') {
  416. expect.fail('Response body must be defined.');
  417. return;
  418. }
  419. expect(resData).toEqual(emplaceResourceData);
  420. });
  421. it('returns data for creation', async () => {
  422. const newId = 2;
  423. vi
  424. .spyOn(DummyDataSource.prototype, 'emplace')
  425. .mockResolvedValueOnce([{
  426. ...existingResource,
  427. ...emplaceResourceData,
  428. id: newId
  429. }, true] as never);
  430. const [res, resData] = await client({
  431. method: 'PUT',
  432. path: `${BASE_PATH}/pianos/${newId}`,
  433. body: {
  434. ...emplaceResourceData,
  435. id: newId,
  436. },
  437. });
  438. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
  439. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
  440. expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
  441. expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`);
  442. if (typeof resData === 'undefined') {
  443. expect.fail('Response body must be defined.');
  444. return;
  445. }
  446. expect(resData).toEqual({
  447. ...emplaceResourceData,
  448. id: newId,
  449. });
  450. });
  451. it('returns options', async () => {
  452. const [res] = await client({
  453. method: 'OPTIONS',
  454. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  455. });
  456. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  457. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  458. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  459. expect(allowedMethods).toContain('PUT');
  460. });
  461. });
  462. describe('deleting items', () => {
  463. const existingResource = {
  464. id: 1,
  465. brand: 'Yamaha'
  466. };
  467. beforeEach(() => {
  468. vi
  469. .spyOn(DummyDataSource.prototype, 'getById')
  470. .mockResolvedValueOnce(existingResource as never);
  471. });
  472. beforeEach(() => {
  473. vi
  474. .spyOn(DummyDataSource.prototype, 'delete')
  475. .mockReturnValueOnce(Promise.resolve() as never);
  476. });
  477. beforeEach(() => {
  478. Piano.canDelete();
  479. });
  480. afterEach(() => {
  481. Piano.canDelete(false);
  482. });
  483. it('responds', async () => {
  484. const [res, resData] = await client({
  485. method: 'DELETE',
  486. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  487. });
  488. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  489. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceDeleted));
  490. expect(res.headers).not.toHaveProperty('content-type');
  491. expect(resData).toBeUndefined();
  492. });
  493. it('returns options', async () => {
  494. const [res] = await client({
  495. method: 'OPTIONS',
  496. path: `${BASE_PATH}/pianos/${existingResource.id}`,
  497. });
  498. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
  499. expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
  500. const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
  501. expect(allowedMethods).toContain('DELETE');
  502. });
  503. });
  504. });