diff --git a/electron/controllers/firestoreController.js b/electron/controllers/firestoreController.js index 5c5f69b..a4d37b3 100644 --- a/electron/controllers/firestoreController.js +++ b/electron/controllers/firestoreController.js @@ -8,7 +8,7 @@ const fs = require('fs'); const vm = require('vm'); const { FieldValue, Filter, Timestamp, GeoPoint } = require('firebase-admin/firestore'); const { fetchDocumentsPage } = require('./firestore/documentList'); -const { firestoreDocumentToData } = require('../utils/firestoreHelpers'); +const { firestoreDocumentToData, toFirestoreAdminValue } = require('../utils/firestoreHelpers'); let dbRef = null; @@ -101,7 +101,7 @@ function registerHandlers() { const docRef = documentId ? dbRef.collection(collectionPath).doc(documentId) : dbRef.collection(collectionPath).doc(); - await docRef.set(data); + await docRef.set(toFirestoreAdminValue(data, { Timestamp, GeoPoint, FieldValue })); return { success: true, documentId: docRef.id }; } catch (error) { return { success: false, error: error.message }; @@ -112,7 +112,7 @@ function registerHandlers() { ipcMain.handle('firestore:updateDocument', async (event, { documentPath, data }) => { try { if (!dbRef) throw new Error('Not connected to Firebase'); - await dbRef.doc(documentPath).update(data); + await dbRef.doc(documentPath).update(toFirestoreAdminValue(data, { Timestamp, GeoPoint, FieldValue })); return { success: true }; } catch (error) { return { success: false, error: error.message }; @@ -123,7 +123,7 @@ function registerHandlers() { ipcMain.handle('firestore:setDocument', async (event, { documentPath, data }) => { try { if (!dbRef) throw new Error('Not connected to Firebase'); - await dbRef.doc(documentPath).set(data); + await dbRef.doc(documentPath).set(toFirestoreAdminValue(data, { Timestamp, GeoPoint, FieldValue })); return { success: true }; } catch (error) { return { success: false, error: error.message }; @@ -212,7 +212,7 @@ function registerHandlers() { for (const [docId, docData] of Object.entries(data)) { const docRef = dbRef.collection(collectionPath).doc(docId); - batch.set(docRef, docData); + batch.set(docRef, toFirestoreAdminValue(docData, { Timestamp, GeoPoint, FieldValue })); count++; if (count >= 500) { await batch.commit(); diff --git a/electron/controllers/firestoreController.test.js b/electron/controllers/firestoreController.test.js index c079c65..9329341 100644 --- a/electron/controllers/firestoreController.test.js +++ b/electron/controllers/firestoreController.test.js @@ -36,11 +36,21 @@ require_.cache[require_.resolve('firebase-admin/firestore')] = { exports: { FieldValue: { serverTimestamp: vi.fn() }, Filter: { where: vi.fn() }, - Timestamp: { now: vi.fn() }, - GeoPoint: vi.fn(), + Timestamp: vi.fn(function (seconds, nanoseconds) { + this.seconds = seconds; + this.nanoseconds = nanoseconds; + }), + GeoPoint: vi.fn(function (latitude, longitude) { + this.latitude = latitude; + this.longitude = longitude; + }), }, }; +const firestoreMock = require_('firebase-admin/firestore'); +const electronMock = require_('electron'); +const fsMock = require_('fs'); + // Load controller with mocked deps const controllerPath = require_.resolve('./firestoreController'); delete require_.cache[controllerPath]; @@ -295,4 +305,92 @@ describe('firestoreController', () => { expect(result.success).toBe(false); expect(result.error).toContain('Not connected'); }); + + // ─── admin write paths convert app-shaped values ───────────────────────── + + const appShapedData = () => ({ + name: 'test', + count: 3, + active: true, + createdAt: { _seconds: 1767225600, _nanoseconds: 500 }, + location: { _latitude: 1.5, _longitude: -2.5 }, + tags: [{ _seconds: 10, _nanoseconds: 0 }, 'plain'], + meta: { nested: { _seconds: 20, _nanoseconds: 1 } }, + }); + + const convertedData = () => ({ + name: 'test', + count: 3, + active: true, + createdAt: { seconds: 1767225600, nanoseconds: 500 }, + location: { latitude: 1.5, longitude: -2.5 }, + tags: [{ seconds: 10, nanoseconds: 0 }, 'plain'], + meta: { nested: { seconds: 20, nanoseconds: 1 } }, + }); + + it('setDocument converts timestamps and geopoints before writing', async () => { + const set = vi.fn().mockResolvedValue(undefined); + setRefs(null, { doc: vi.fn().mockReturnValue({ set }) }); + const data = appShapedData(); + + const result = await handlers['firestore:setDocument'](null, { documentPath: 'col/doc', data }); + + expect(result.success).toBe(true); + expect(firestoreMock.Timestamp).toHaveBeenCalled(); + expect(firestoreMock.GeoPoint).toHaveBeenCalled(); + expect(set.mock.calls[0][0]).toEqual(convertedData()); + // The incoming payload must not be mutated. + expect(data.createdAt).toEqual({ _seconds: 1767225600, _nanoseconds: 500 }); + expect(data.location).toEqual({ _latitude: 1.5, _longitude: -2.5 }); + }); + + it('updateDocument converts timestamps and geopoints before writing', async () => { + const update = vi.fn().mockResolvedValue(undefined); + setRefs(null, { doc: vi.fn().mockReturnValue({ update }) }); + const data = appShapedData(); + + const result = await handlers['firestore:updateDocument'](null, { documentPath: 'col/doc', data }); + + expect(result.success).toBe(true); + expect(update.mock.calls[0][0]).toEqual(convertedData()); + expect(data.createdAt).toEqual({ _seconds: 1767225600, _nanoseconds: 500 }); + }); + + it('createDocument converts timestamps and geopoints before writing', async () => { + const set = vi.fn().mockResolvedValue(undefined); + const docRef = { id: 'new-id', set }; + const mockCollection = { doc: vi.fn().mockReturnValue(docRef) }; + setRefs(null, { collection: vi.fn().mockReturnValue(mockCollection) }); + const data = appShapedData(); + + const result = await handlers['firestore:createDocument'](null, { + collectionPath: 'col', + documentId: 'new-id', + data, + }); + + expect(result.success).toBe(true); + expect(result.documentId).toBe('new-id'); + expect(set.mock.calls[0][0]).toEqual(convertedData()); + }); + + it('importDocuments converts timestamps and geopoints for each batch set', async () => { + const batchSet = vi.fn(); + const commit = vi.fn().mockResolvedValue(undefined); + const doc = vi.fn().mockReturnValue({}); + setRefs(null, { + collection: vi.fn().mockReturnValue({ doc }), + batch: vi.fn().mockReturnValue({ set: batchSet, commit }), + }); + electronMock.dialog.showOpenDialog.mockResolvedValue({ filePaths: ['/tmp/import.json'] }); + fsMock.readFileSync.mockReturnValue(JSON.stringify({ doc1: appShapedData(), doc2: { plain: 'value' } })); + + const result = await handlers['firestore:importDocuments'](null, 'col'); + + expect(result.success).toBe(true); + expect(result.count).toBe(2); + expect(batchSet.mock.calls[0][1]).toEqual(convertedData()); + expect(batchSet.mock.calls[1][1]).toEqual({ plain: 'value' }); + expect(commit).toHaveBeenCalled(); + }); }); diff --git a/electron/utils/firestoreHelpers.js b/electron/utils/firestoreHelpers.js index 1a8813d..2a2023f 100644 --- a/electron/utils/firestoreHelpers.js +++ b/electron/utils/firestoreHelpers.js @@ -42,6 +42,57 @@ function convertToFirestoreValue(value) { return { stringValue: String(value) }; } +/** + * Converts app-shaped data into firebase-admin compatible values. + * The UI works with `{ _seconds, _nanoseconds }` timestamps and + * `{ _latitude, _longitude }` geopoints; the Admin SDK needs real + * Timestamp/GeoPoint instances, otherwise it persists them as plain maps. + * Constructors are injected so this module stays free of firebase-admin. + * @param {*} value - App-shaped value to convert + * @param {Object} opts - { Timestamp, GeoPoint, FieldValue } constructors + * @returns {*} - Value safe to hand to the firebase-admin SDK + */ +function toFirestoreAdminValue(value, { Timestamp, GeoPoint, FieldValue } = {}) { + if (value === null || value === undefined) return value; + if (value instanceof Date) { + const time = value.getTime(); + return new Timestamp(Math.floor(time / 1000), (time % 1000) * 1000000); + } + if (typeof value !== 'object') return value; + + // Sentinels (serverTimestamp, delete, increment, ...) must pass through. + if (typeof FieldValue === 'function' && value instanceof FieldValue) return value; + + if (Array.isArray(value)) { + return value.map((item) => toFirestoreAdminValue(item, { Timestamp, GeoPoint, FieldValue })); + } + + // Timestamp shapes: { _seconds, _nanoseconds } or { seconds, nanoseconds }. + const nanos = value._nanoseconds ?? value.nanoseconds ?? value.nanos; + const underscoredSeconds = typeof value._seconds === 'number' ? value._seconds : undefined; + const plainSeconds = typeof value.seconds === 'number' ? value.seconds : undefined; + if (underscoredSeconds !== undefined || (plainSeconds !== undefined && nanos !== undefined)) { + const seconds = underscoredSeconds !== undefined ? underscoredSeconds : plainSeconds; + return new Timestamp(seconds, nanos || 0); + } + + // GeoPoint shapes: { _latitude, _longitude } or { latitude, longitude }. + const underscoredLatLng = value._latitude !== undefined && value._longitude !== undefined; + const plainLatLng = value.latitude !== undefined && value.longitude !== undefined; + if (underscoredLatLng || plainLatLng) { + const latitude = underscoredLatLng ? value._latitude : value.latitude; + const longitude = underscoredLatLng ? value._longitude : value.longitude; + return new GeoPoint(latitude, longitude); + } + + // Plain map: rebuild recursively, never mutate the input. + const result = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = toFirestoreAdminValue(item, { Timestamp, GeoPoint, FieldValue }); + } + return result; +} + /** * Parses a Firestore REST API value to JS value * @param {Object} value - Firestore REST API value object @@ -118,6 +169,7 @@ function firestoreDocumentToData(doc) { module.exports = { convertToFirestoreValue, + toFirestoreAdminValue, parseFirestoreValue, parseFirestoreDocument, dataToFirestoreFields, diff --git a/electron/utils/firestoreHelpers.test.js b/electron/utils/firestoreHelpers.test.js new file mode 100644 index 0000000..c22c714 --- /dev/null +++ b/electron/utils/firestoreHelpers.test.js @@ -0,0 +1,112 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach } from 'vitest'; +import { createRequire } from 'module'; + +const require_ = createRequire(import.meta.url); +const { toFirestoreAdminValue } = require_('./firestoreHelpers'); + +class FakeTimestamp { + constructor(seconds, nanoseconds) { + this.seconds = seconds; + this.nanoseconds = nanoseconds; + } +} + +class FakeGeoPoint { + constructor(latitude, longitude) { + this.latitude = latitude; + this.longitude = longitude; + } +} + +class FakeFieldValue {} + +const opts = () => ({ Timestamp: FakeTimestamp, GeoPoint: FakeGeoPoint, FieldValue: FakeFieldValue }); + +describe('toFirestoreAdminValue', () => { + it('converts a Date into a Timestamp with second and nanosecond parts', () => { + const result = toFirestoreAdminValue(new Date('2026-01-01T00:00:00.000Z'), opts()); + + expect(result).toBeInstanceOf(FakeTimestamp); + expect(result).toEqual({ seconds: 1767225600, nanoseconds: 0 }); + }); + + it('preserves sub-second milliseconds as nanoseconds', () => { + const result = toFirestoreAdminValue(new Date(1500), opts()); + + expect(result).toEqual({ seconds: 1, nanoseconds: 500000000 }); + }); + + it('converts the { _seconds, _nanoseconds } shape', () => { + const result = toFirestoreAdminValue({ _seconds: 1767225600, _nanoseconds: 42 }, opts()); + + expect(result).toBeInstanceOf(FakeTimestamp); + expect(result).toEqual({ seconds: 1767225600, nanoseconds: 42 }); + }); + + it('converts the { seconds, nanoseconds } shape', () => { + const result = toFirestoreAdminValue({ seconds: 1767225600, nanoseconds: 7 }, opts()); + + expect(result).toBeInstanceOf(FakeTimestamp); + expect(result).toEqual({ seconds: 1767225600, nanoseconds: 7 }); + }); + + it('defaults missing nanos to zero for the seconds shape', () => { + const result = toFirestoreAdminValue({ seconds: 10, nanos: 3 }, opts()); + + expect(result).toEqual({ seconds: 10, nanoseconds: 3 }); + }); + + it('converts { _latitude, _longitude } geopoints', () => { + const result = toFirestoreAdminValue({ _latitude: 1.5, _longitude: -2.5 }, opts()); + + expect(result).toBeInstanceOf(FakeGeoPoint); + expect(result).toEqual({ latitude: 1.5, longitude: -2.5 }); + }); + + it('converts { latitude, longitude } geopoints', () => { + const result = toFirestoreAdminValue({ latitude: 40.7, longitude: -74 }, opts()); + + expect(result).toEqual({ latitude: 40.7, longitude: -74 }); + }); + + it('converts values nested inside arrays and plain maps', () => { + const input = { + tags: [{ _seconds: 1, _nanoseconds: 0 }, 'plain', 5], + meta: { place: { _latitude: 1, _longitude: 2 } }, + }; + + const result = toFirestoreAdminValue(input, opts()); + + expect(result.tags[0]).toBeInstanceOf(FakeTimestamp); + expect(result).toEqual({ + tags: [{ seconds: 1, nanoseconds: 0 }, 'plain', 5], + meta: { place: { latitude: 1, longitude: 2 } }, + }); + }); + + it('passes primitives through unchanged', () => { + expect(toFirestoreAdminValue(null, opts())).toBeNull(); + expect(toFirestoreAdminValue(undefined, opts())).toBeUndefined(); + expect(toFirestoreAdminValue('text', opts())).toBe('text'); + expect(toFirestoreAdminValue(3, opts())).toBe(3); + expect(toFirestoreAdminValue(true, opts())).toBe(true); + }); + + it('returns FieldValue sentinels unchanged', () => { + const sentinel = new FakeFieldValue(); + + expect(toFirestoreAdminValue(sentinel, opts())).toBe(sentinel); + }); + + it('does not mutate the input object and rebuilds plain maps', () => { + const input = { createdAt: { _seconds: 5, _nanoseconds: 0 }, nested: { a: 1 } }; + + const result = toFirestoreAdminValue(input, opts()); + + expect(result).not.toBe(input); + expect(result.nested).not.toBe(input.nested); + expect(input.createdAt).toEqual({ _seconds: 5, _nanoseconds: 0 }); + expect(input.nested).toEqual({ a: 1 }); + }); +});