HATEOAS-first backend framework.
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

default.test.ts 12 KiB

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