Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions electron/controllers/firestoreController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
Expand All @@ -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 };
Expand Down Expand Up @@ -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();
Expand Down
102 changes: 100 additions & 2 deletions electron/controllers/firestoreController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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();
});
});
52 changes: 52 additions & 0 deletions electron/utils/firestoreHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -118,6 +169,7 @@ function firestoreDocumentToData(doc) {

module.exports = {
convertToFirestoreValue,
toFirestoreAdminValue,
parseFirestoreValue,
parseFirestoreDocument,
dataToFirestoreFields,
Expand Down
112 changes: 112 additions & 0 deletions electron/utils/firestoreHelpers.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading