From 301fa535afeeae168898671678ae69e8a58eff0c Mon Sep 17 00:00:00 2001 From: Camillo bucciarelli Date: Thu, 25 Jul 2024 11:32:54 +0200 Subject: [PATCH] INFRA30 (#251) * Refactor firestore utilities for Astro --- package.json | 2 + pnpm-lock.yaml | 80 ++++++++++++- src/data/firestore/common/create.spec.ts | 105 ++++++++++++++++ src/data/firestore/common/create.ts | 51 ++++++++ src/data/firestore/common/delete.spec.ts | 86 +++++++++++++ src/data/firestore/common/delete.ts | 38 ++++++ src/data/firestore/common/get-by-id.spec.ts | 111 +++++++++++++++++ src/data/firestore/common/get-by-id.ts | 67 +++++++++++ .../common/get-docs-paginated.spec.ts | 46 ++++++- .../firestore/common/get-docs-paginated.ts | 4 +- src/data/firestore/common/update.spec.ts | 113 ++++++++++++++++++ src/data/firestore/common/update.ts | 49 ++++++++ src/data/firestore/user.spec.ts | 10 +- src/firebase/server.ts | 2 +- 14 files changed, 745 insertions(+), 19 deletions(-) create mode 100644 src/data/firestore/common/create.spec.ts create mode 100644 src/data/firestore/common/create.ts create mode 100644 src/data/firestore/common/delete.spec.ts create mode 100644 src/data/firestore/common/delete.ts create mode 100644 src/data/firestore/common/get-by-id.spec.ts create mode 100644 src/data/firestore/common/get-by-id.ts create mode 100644 src/data/firestore/common/update.spec.ts create mode 100644 src/data/firestore/common/update.ts diff --git a/package.json b/package.json index af0d2d6..d48b30f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "generate-translations": "npx astro-i18next generate", "prettier": "prettier --write . --plugin=prettier-plugin-astro", "test": "firebase emulators:exec --only firestore 'vitest --coverage'", + "test:ui": "firebase emulators:exec --only firestore 'vitest --coverage --ui'", "test:e2e": "playwright test", "emulators": "cd functions/ && npm run build && cd .. && firebase emulators:start --import=./firebase-data", "emulators:export": "firebase emulators:export ./firebase-data" @@ -47,6 +48,7 @@ "@types/node": "^20.11.17", "@typescript-eslint/parser": "^6.21.0", "@vitest/coverage-v8": "^1.2.2", + "@vitest/ui": "^2.0.4", "eslint": "^8.56.0", "eslint-plugin-astro": "^0.30.0", "eslint-plugin-jsx-a11y": "^6.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 676afff..40e62ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ devDependencies: '@vitest/coverage-v8': specifier: ^1.2.2 version: 1.2.2(vitest@1.2.2) + '@vitest/ui': + specifier: ^2.0.4 + version: 2.0.4(vitest@1.2.2) eslint: specifier: ^8.56.0 version: 8.56.0 @@ -126,7 +129,7 @@ devDependencies: version: 2.2.1 vitest: specifier: ^1.2.2 - version: 1.2.2(@types/node@20.11.17)(sass@1.69.5) + version: 1.2.2(@types/node@20.11.17)(@vitest/ui@2.0.4)(sass@1.69.5) vitest-github-actions-reporter: specifier: ^0.11.1 version: 0.11.1(vitest@1.2.2) @@ -1924,6 +1927,10 @@ packages: resolution: {integrity: sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==} dev: false + /@polka/url@1.0.0-next.25: + resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} + dev: true + /@proload/core@0.3.3: resolution: {integrity: sha512-7dAFWsIK84C90AMl24+N/ProHKm4iw0akcnoKjRvbfHifJZBLhaDsDus1QJmhG12lXj4e/uB/8mB/0aduCW+NQ==} dependencies: @@ -2550,7 +2557,7 @@ packages: std-env: 3.7.0 test-exclude: 6.0.0 v8-to-istanbul: 9.2.0 - vitest: 1.2.2(@types/node@20.11.17)(sass@1.69.5) + vitest: 1.2.2(@types/node@20.11.17)(@vitest/ui@2.0.4)(sass@1.69.5) transitivePeerDependencies: - supports-color dev: true @@ -2563,6 +2570,12 @@ packages: chai: 4.4.1 dev: true + /@vitest/pretty-format@2.0.4: + resolution: {integrity: sha512-RYZl31STbNGqf4l2eQM1nvKPXE0NhC6Eq0suTTePc4mtMQ1Fn8qZmjV4emZdEdG2NOWGKSCrHZjmTqDCDoeFBw==} + dependencies: + tinyrainbow: 1.2.0 + dev: true + /@vitest/runner@1.2.2: resolution: {integrity: sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==} dependencies: @@ -2585,6 +2598,21 @@ packages: tinyspy: 2.2.0 dev: true + /@vitest/ui@2.0.4(vitest@1.2.2): + resolution: {integrity: sha512-9SNE9ve3kgDkVTxJsY7BjqSwyqDVRJbq/AHVHZs+V0vmr/0cCX6yGT6nOahSXEsXFtKAsvRtBXKlTgr+5njzZQ==} + peerDependencies: + vitest: 2.0.4 + dependencies: + '@vitest/utils': 2.0.4 + fast-glob: 3.3.2 + fflate: 0.8.2 + flatted: 3.3.1 + pathe: 1.1.2 + sirv: 2.0.4 + tinyrainbow: 1.2.0 + vitest: 1.2.2(@types/node@20.11.17)(@vitest/ui@2.0.4)(sass@1.69.5) + dev: true + /@vitest/utils@1.2.2: resolution: {integrity: sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==} dependencies: @@ -2594,6 +2622,15 @@ packages: pretty-format: 29.7.0 dev: true + /@vitest/utils@2.0.4: + resolution: {integrity: sha512-Zc75QuuoJhOBnlo99ZVUkJIuq4Oj0zAkrQ2VzCqNCx6wAwViHEh5Fnp4fiJTE9rA+sAoXRf00Z9xGgfEzV6fzQ==} + dependencies: + '@vitest/pretty-format': 2.0.4 + estree-walker: 3.0.3 + loupe: 3.1.1 + tinyrainbow: 1.2.0 + dev: true + /@vscode/emmet-helper@2.9.2: resolution: {integrity: sha512-MaGuyW+fa13q3aYsluKqclmh62Hgp0BpKIqS66fCxfOaBcVQ1OnMQxRRgQUYnCkxFISAQlkJ0qWWPyXjro1Qrg==} dependencies: @@ -4881,6 +4918,10 @@ packages: web-streams-polyfill: 3.2.1 dev: false + /fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5020,6 +5061,10 @@ packages: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} dev: true + /flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + dev: true + /flattie@1.1.0: resolution: {integrity: sha512-xU99gDEnciIwJdGcBmNHnzTJ/w5AT+VFJOu6sTB6WM8diOYNA3Sa+K1DiEBQ7XH4QikQq3iFW1U+jRVcotQnBw==} engines: {node: '>=8'} @@ -6407,6 +6452,12 @@ packages: get-func-name: 2.0.2 dev: true + /loupe@3.1.1: + resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==} + dependencies: + get-func-name: 2.0.2 + dev: true + /lru-cache@4.0.2: resolution: {integrity: sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==} dependencies: @@ -7469,6 +7520,11 @@ packages: engines: {node: '>=10'} dev: false + /mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + dev: true + /ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} dev: false @@ -8939,6 +8995,15 @@ packages: totalist: 3.0.1 dev: false + /sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.25 + mrmime: 2.0.0 + totalist: 3.0.1 + dev: true + /sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: false @@ -9381,6 +9446,11 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + dev: true + /tinyspy@2.2.0: resolution: {integrity: sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==} engines: {node: '>=14.0.0'} @@ -9408,7 +9478,6 @@ packages: /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - dev: false /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -9971,10 +10040,10 @@ packages: vitest: '>=0.28.5' dependencies: '@actions/core': 1.10.1 - vitest: 1.2.2(@types/node@20.11.17)(sass@1.69.5) + vitest: 1.2.2(@types/node@20.11.17)(@vitest/ui@2.0.4)(sass@1.69.5) dev: true - /vitest@1.2.2(@types/node@20.11.17)(sass@1.69.5): + /vitest@1.2.2(@types/node@20.11.17)(@vitest/ui@2.0.4)(sass@1.69.5): resolution: {integrity: sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -10004,6 +10073,7 @@ packages: '@vitest/runner': 1.2.2 '@vitest/snapshot': 1.2.2 '@vitest/spy': 1.2.2 + '@vitest/ui': 2.0.4(vitest@1.2.2) '@vitest/utils': 1.2.2 acorn-walk: 8.3.2 cac: 6.7.14 diff --git a/src/data/firestore/common/create.spec.ts b/src/data/firestore/common/create.spec.ts new file mode 100644 index 0000000..acb7786 --- /dev/null +++ b/src/data/firestore/common/create.spec.ts @@ -0,0 +1,105 @@ +import { test, describe, expect } from "vitest"; +import { createDocument } from "./create"; +import { getDocumentById } from "./get-by-id"; +import { testConverter, type TestDoc, type TestModel } from "./test-model"; +import type { FirestoreDataConverter } from "firebase-admin/firestore"; + +const testCollection = "create-test"; + +describe("Create a new document", () => { + test("Should create a document success", async () => { + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + const result = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + expect(result).toMatchObject({ + status: "success", + data: expect.any(String), + }); + + const getResult = await getDocumentById( + testCollection, + testConverter, + result.data as string, + ); + expect(getResult).toMatchObject({ + status: "success", + data: expect.objectContaining({ + ...modelToSave, + id: result.data, + }), + }); + }); + + test("Should return an error if id is present", async () => { + const modelToSave: TestModel = { + id: "test-id", + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + const result = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/create-error:id-is-present`, + message: "Document ID is required", + }, + }); + }); + + test("Should return an error if firestore error", async () => { + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + const fakeConverter: FirestoreDataConverter = { + toFirestore: (): TestDoc => { + throw new Error("Error converting to firestore"); + }, + fromFirestore: (): TestModel => { + throw new Error("Error converting from firestore"); + }, + }; + const result = await createDocument( + testCollection, + fakeConverter, + modelToSave, + ); + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/create-error`, + message: `Error creating document in collection ${testCollection}`, + }, + }); + }); + + test("Should return an error if id is present", async () => { + const modelToSave: TestModel = { + id: "test-id", + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + const result = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/create-error:id-is-present`, + message: "Document ID is required", + }, + }); + }); +}); diff --git a/src/data/firestore/common/create.ts b/src/data/firestore/common/create.ts new file mode 100644 index 0000000..81a98bc --- /dev/null +++ b/src/data/firestore/common/create.ts @@ -0,0 +1,51 @@ +import type { + DocumentData, + FirestoreDataConverter, +} from "firebase-admin/firestore"; +import { firestoreInstance } from "~/firebase/server"; +import type { BaseModel } from "~/models/base-model"; +import type { ServerResponse } from "~/models/server-response/server-response.type"; + +export const createDocument = async < + M extends BaseModel, + D extends DocumentData, + C extends string, +>( + collection: C, + converter: FirestoreDataConverter, + model: M, +): Promise< + ServerResponse< + string, + `${C}/create-error` | `${C}/create-error:id-is-present` + > +> => { + try { + if (model.id) { + return { + status: "error", + data: { + code: `${collection}/create-error:id-is-present`, + message: "Document ID is required", + }, + }; + } + const result = await firestoreInstance + .collection(collection) + .withConverter(converter) + .add(model); + return { + status: "success", + data: result.id, + }; + } catch (error) { + console.error(`Error creating document in collection ${collection}`, error); + return { + status: "error", + data: { + code: `${collection}/create-error`, + message: `Error creating document in collection ${collection}`, + }, + }; + } +}; diff --git a/src/data/firestore/common/delete.spec.ts b/src/data/firestore/common/delete.spec.ts new file mode 100644 index 0000000..1babd5f --- /dev/null +++ b/src/data/firestore/common/delete.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "vitest"; +import { deleteDocument } from "./delete"; +import { createDocument } from "./create"; +import { getDocumentById } from "./get-by-id"; +import { testConverter, type TestModel } from "./test-model"; + +const testCollection = "delete-test"; + +describe("deleteDocument", () => { + test("should delete a document from the specified collection", async () => { + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + + const result = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + + const get = await getDocumentById( + testCollection, + testConverter, + result.data as string, + ); + + expect(get).toMatchObject({ + status: "success", + data: expect.objectContaining({ + ...modelToSave, + id: result.data, + }), + }); + + const deleteResult = await deleteDocument( + testCollection, + result.data as string, + ); + + expect(deleteResult).toMatchObject({ + status: "success", + data: null, + }); + + const getAfterDelete = await getDocumentById( + testCollection, + testConverter, + result.data as string, + ); + + expect(getAfterDelete).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/get-by-id-error:not-found`, + message: `Document with id ${ + result.data as string + } not found in collection delete-test`, + }, + }); + }); + + test("should return an error if id is missing", async () => { + const result = await deleteDocument(testCollection, ""); + + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/delete-error:missing-id`, + message: "Document ID is required", + }, + }); + }); + + test("should return an error if Firebase throw error", async () => { + const result = await deleteDocument("", "fake-id"); + + expect(result).toMatchObject({ + status: "error", + data: { + code: `/delete-error`, + message: `Error deleting document with id fake-id from collection `, + }, + }); + }); +}); diff --git a/src/data/firestore/common/delete.ts b/src/data/firestore/common/delete.ts new file mode 100644 index 0000000..95eecd3 --- /dev/null +++ b/src/data/firestore/common/delete.ts @@ -0,0 +1,38 @@ +import { firestoreInstance } from "~/firebase/server"; +import type { ServerResponse } from "~/models/server-response/server-response.type"; + +export const deleteDocument = async ( + collection: C, + docId: string, +): Promise< + ServerResponse +> => { + if (!docId || docId.trim() === "") { + return { + status: "error", + data: { + code: `${collection}/delete-error:missing-id`, + message: "Document ID is required", + }, + }; + } + try { + await firestoreInstance.collection(collection).doc(docId).delete(); + return { + status: "success", + data: null, + }; + } catch (error) { + console.error( + `Error deleting document with id ${docId} from collection ${collection}`, + error, + ); + return { + status: "error", + data: { + code: `${collection}/delete-error`, + message: `Error deleting document with id ${docId} from collection ${collection}`, + }, + }; + } +}; diff --git a/src/data/firestore/common/get-by-id.spec.ts b/src/data/firestore/common/get-by-id.spec.ts new file mode 100644 index 0000000..5d7477d --- /dev/null +++ b/src/data/firestore/common/get-by-id.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vitest"; +import { createDocument } from "./create"; +import { getDocumentById } from "./get-by-id"; +import { testConverter, type TestDoc, type TestModel } from "./test-model"; +import type { FirestoreDataConverter } from "firebase-admin/firestore"; + +const testCollection = "get-by-id-test"; + +describe("Get by id", () => { + test("should get document from the specified collection", async () => { + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + + const result = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + + const get = await getDocumentById( + testCollection, + testConverter, + result.data as string, + ); + + expect(get).toMatchObject({ + status: "success", + data: expect.objectContaining({ + ...modelToSave, + id: result.data, + }), + }); + }); + + test("should return an error if id is missing", async () => { + const result = await getDocumentById(testCollection, testConverter, " "); + + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/get-by-id-error:missing-id`, + message: "Document ID is required", + }, + }); + }); + + test("should return an error if id is missing", async () => { + const result = await getDocumentById(testCollection, testConverter, ""); + + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/get-by-id-error:missing-id`, + message: "Document ID is required", + }, + }); + }); + + test("should return an error if document not found", async () => { + const result = await getDocumentById( + testCollection, + testConverter, + "not-found", + ); + + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/get-by-id-error:not-found`, + message: `Document with id not-found not found in collection ${testCollection}`, + }, + }); + }); + + test("should return an error if firestore error", async () => { + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + + const resultInsert = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + + const fakeConverter: FirestoreDataConverter = { + toFirestore: (): TestDoc => { + throw new Error("Error converting to firestore"); + }, + fromFirestore: (): TestModel => { + throw new Error("Error converting from firestore"); + }, + }; + const result = await getDocumentById( + testCollection, + fakeConverter, + resultInsert.data as string, + ); + + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/get-by-id-error`, + message: `Error getting document with id ${resultInsert.data as string} from collection ${testCollection}`, + }, + }); + }); +}); diff --git a/src/data/firestore/common/get-by-id.ts b/src/data/firestore/common/get-by-id.ts new file mode 100644 index 0000000..943b7bf --- /dev/null +++ b/src/data/firestore/common/get-by-id.ts @@ -0,0 +1,67 @@ +import type { + DocumentData, + FirestoreDataConverter, +} from "firebase-admin/firestore"; +import { firestoreInstance } from "~/firebase/server"; +import type { BaseModel } from "~/models/base-model"; +import type { ServerResponse } from "~/models/server-response/server-response.type"; + +export const getDocumentById = async < + M extends BaseModel, + D extends DocumentData, + C extends string, +>( + collection: C, + converter: FirestoreDataConverter, + docId: string, +): Promise< + ServerResponse< + M, + | `${C}/get-by-id-error` + | `${C}/get-by-id-error:missing-id` + | `${C}/get-by-id-error:not-found` + > +> => { + if (!docId || docId.trim() === "") { + return { + status: "error", + data: { + code: `${collection}/get-by-id-error:missing-id`, + message: "Document ID is required", + }, + }; + } + try { + const document = await firestoreInstance + .collection(collection) + .withConverter(converter) + .doc(docId) + .get(); + const data = document.data(); + if (!data) { + return { + status: "error", + data: { + code: `${collection}/get-by-id-error:not-found`, + message: `Document with id ${docId} not found in collection ${collection}`, + }, + }; + } + return { + status: "success", + data: data, + }; + } catch (error) { + console.error( + `Error getting document with id ${docId} from collection ${collection}`, + error, + ); + return { + status: "error", + data: { + code: `${collection}/get-by-id-error`, + message: `Error getting document with id ${docId} from collection ${collection}`, + }, + }; + } +}; diff --git a/src/data/firestore/common/get-docs-paginated.spec.ts b/src/data/firestore/common/get-docs-paginated.spec.ts index 19f5928..101bc6f 100644 --- a/src/data/firestore/common/get-docs-paginated.spec.ts +++ b/src/data/firestore/common/get-docs-paginated.spec.ts @@ -4,8 +4,9 @@ import type { PaginationParams, } from "~/models/pagination/pagination.type"; import getDocsPaginated from "./get-docs-paginated"; -import { firestore } from "~/firebase/server"; -import { testConverter, type TestModel } from "./test-model"; +import { firestoreInstance } from "~/firebase/server"; +import { testConverter, type TestDoc, type TestModel } from "./test-model"; +import type { FirestoreDataConverter } from "firebase-admin/firestore"; const testCollection = "get-paginated-test"; @@ -42,10 +43,13 @@ describe("Get docs paginated", () => { ]; beforeEach(async () => { - const batch = firestore.batch(); + const batch = firestoreInstance.batch(); modelsToSave.forEach((model) => { batch.create( - firestore.collection(testCollection).withConverter(testConverter).doc(), + firestoreInstance + .collection(testCollection) + .withConverter(testConverter) + .doc(), model, ); }); @@ -53,11 +57,11 @@ describe("Get docs paginated", () => { }); afterEach(async () => { - const docs = await firestore + const docs = await firestoreInstance .collection(testCollection) .withConverter(testConverter) .listDocuments(); - const batch = firestore.batch(); + const batch = firestoreInstance.batch(); docs.forEach((doc) => { batch.delete(doc); }); @@ -247,4 +251,34 @@ describe("Get docs paginated", () => { }, }); }); + + test("Should return an error if firestore error", async () => { + const fakeConverter: FirestoreDataConverter = { + toFirestore: (): TestDoc => { + throw new Error("Error converting to firestore"); + }, + fromFirestore: (): TestModel => { + throw new Error("Error converting from firestore"); + }, + }; + const params: PaginationParams = { + offset: 6, + limit: 3, + orderBy: "name", + orderDirection: "desc", + }; + + const result = await getDocsPaginated( + testCollection, + fakeConverter, + params, + ); + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/search-error`, + message: `Error getting documents from ${testCollection}`, + }, + }); + }); }); diff --git a/src/data/firestore/common/get-docs-paginated.ts b/src/data/firestore/common/get-docs-paginated.ts index b2cc25d..bdff774 100644 --- a/src/data/firestore/common/get-docs-paginated.ts +++ b/src/data/firestore/common/get-docs-paginated.ts @@ -2,7 +2,7 @@ import { type DocumentData, type FirestoreDataConverter, } from "firebase-admin/firestore"; -import { firestore } from "~/firebase/server"; +import { firestoreInstance } from "~/firebase/server"; import type { PaginatedResponse, PaginationParams, @@ -65,7 +65,7 @@ const getDocsPaginated = async < }, }; } - const collectionRef = firestore.collection(collection); + const collectionRef = firestoreInstance.collection(collection); let query = collectionRef .withConverter(converter) .orderBy(params.orderBy, params.orderDirection); diff --git a/src/data/firestore/common/update.spec.ts b/src/data/firestore/common/update.spec.ts new file mode 100644 index 0000000..0b80c6a --- /dev/null +++ b/src/data/firestore/common/update.spec.ts @@ -0,0 +1,113 @@ +import { test, describe, expect } from "vitest"; +import { updateDocument } from "./update"; +import { getDocumentById } from "./get-by-id"; +import { createDocument } from "./create"; +import { testConverter, type TestDoc, type TestModel } from "./test-model"; +import type { FirestoreDataConverter } from "firebase-admin/firestore"; + +const testCollection = "update-test"; + +describe("Update a new document", () => { + test("Should update a document success", async () => { + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + + const result = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + + const modelToUpdate = { + id: result.data as string, + ...modelToSave, + name: "Updated Document", + }; + + const updateResult = await updateDocument( + testCollection, + testConverter, + modelToUpdate, + ); + + expect(updateResult).toMatchObject({ + status: "success", + data: null, + }); + + const getResult = await getDocumentById( + testCollection, + testConverter, + modelToUpdate.id as string, + ); + expect(getResult).toMatchObject({ + status: "success", + data: expect.objectContaining({ + ...modelToUpdate, + id: result.data, + }), + }); + }); + + test("Should return an error if missing id", async () => { + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + const result = await updateDocument( + testCollection, + testConverter, + modelToSave, + ); + expect(result).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/update-error:missing-id`, + message: "Document ID is required", + }, + }); + }); + + test("Should return an error if firestore error", async () => { + const fakeConverter: FirestoreDataConverter = { + toFirestore: (): TestDoc => { + throw new Error("Error converting to firestore"); + }, + fromFirestore: (): TestModel => { + throw new Error("Error converting from firestore"); + }, + }; + const modelToSave: TestModel = { + lastUpdate: new Date("2024-01-01"), + name: "Test Document", + }; + + const result = await createDocument( + testCollection, + testConverter, + modelToSave, + ); + + const modelToUpdate = { + id: result.data as string, + ...modelToSave, + name: "Updated Document", + }; + + const updateResult = await updateDocument( + testCollection, + fakeConverter, + modelToUpdate, + ); + + expect(updateResult).toMatchObject({ + status: "error", + data: { + code: `${testCollection}/update-error`, + message: `Error updating document ${result.data as string}`, + }, + }); + }); +}); diff --git a/src/data/firestore/common/update.ts b/src/data/firestore/common/update.ts new file mode 100644 index 0000000..1d0a841 --- /dev/null +++ b/src/data/firestore/common/update.ts @@ -0,0 +1,49 @@ +import type { + DocumentData, + FirestoreDataConverter, +} from "firebase-admin/firestore"; +import { firestoreInstance } from "~/firebase/server"; +import type { BaseModel } from "~/models/base-model"; +import type { ServerResponse } from "~/models/server-response/server-response.type"; + +export const updateDocument = async < + M extends BaseModel, + D extends DocumentData, + C extends string, +>( + collection: C, + converter: FirestoreDataConverter, + model: M, +): Promise< + ServerResponse +> => { + if (!model.id || model.id.trim() === "") { + return { + status: "error", + data: { + code: `${collection}/update-error:missing-id`, + message: "Document ID is required", + }, + }; + } + try { + await firestoreInstance + .collection(collection) + .withConverter(converter) + .doc(model.id) + .set(model, { merge: true }); + return { + status: "success", + data: null, + }; + } catch (error) { + console.error(`Error updating document ${model.id}`, error); + return { + status: "error", + data: { + code: `${collection}/update-error`, + message: `Error updating document ${model.id}`, + }, + }; + } +}; diff --git a/src/data/firestore/user.spec.ts b/src/data/firestore/user.spec.ts index 1d61a09..85217d9 100644 --- a/src/data/firestore/user.spec.ts +++ b/src/data/firestore/user.spec.ts @@ -3,7 +3,7 @@ import type { PaginatedResponse, PaginationParams, } from "~/models/pagination/pagination.type"; -import { firestore } from "~/firebase/server"; +import { firestoreInstance } from "~/firebase/server"; import { testConverter } from "./common/test-model"; import type { User } from "~/models/user/user.type"; import { getUsersPaginated } from "./user"; @@ -45,19 +45,19 @@ describe("Get docs paginated", () => { ]; beforeEach(async () => { - const batch = firestore.batch(); + const batch = firestoreInstance.batch(); modelsToSave.forEach((model) => { - batch.create(firestore.collection(testCollection).doc(), model); + batch.create(firestoreInstance.collection(testCollection).doc(), model); }); await batch.commit(); }); afterEach(async () => { - const docs = await firestore + const docs = await firestoreInstance .collection(testCollection) .withConverter(testConverter) .listDocuments(); - const batch = firestore.batch(); + const batch = firestoreInstance.batch(); docs.forEach((doc) => { batch.delete(doc); }); diff --git a/src/firebase/server.ts b/src/firebase/server.ts index cc62ef8..8f25f32 100644 --- a/src/firebase/server.ts +++ b/src/firebase/server.ts @@ -16,4 +16,4 @@ export const app = : activeApps[0]; export const auth = getAuth(app); -export const firestore = getFirestore(app); +export const firestoreInstance = getFirestore(app);