Skip to content

Commit

Permalink
Merge branch 'feat/campsite-timestamps'
Browse files Browse the repository at this point in the history
  • Loading branch information
th0rgall committed Aug 12, 2024
2 parents ee67fee + a58debe commit 23da3d3
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 5 deletions.
6 changes: 6 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ This example (run from the `api` folder) runs tests in the group that includes t
echo "node_modules/.bin/mocha -f sendMessageFromEmail" > runtests.sh && firebase --project demo-test emulators:exec --only auth,firestore --ui ./runtests.sh
```

Or, when functions or Firestore triggers should also be tested:

```
echo "node_modules/.bin/mocha -w -f onCampsitesWrite" > runtests.sh && firebase --project demo-test emulators:exec --only auth,firestore,functions --ui ./runtests.sh
```

Some unit tests can be run without starting firebase emulators, because they don't have Firebase dependencies, or their dependencies (like `logger` in from `functions-framework`) work standalone.

```
Expand Down
3 changes: 1 addition & 2 deletions api/runtests.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
# called from ./api
node_modules/.bin/mocha -w
node_modules/.bin/mocha -w -f onCampsitesWrite
53 changes: 51 additions & 2 deletions api/src/replication/onCampsitesWrite.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,62 @@
const { Timestamp } = require('firebase-admin/firestore');
const { replicate } = require('./shared');

/**
* @param {import("firebase-functions").Change<any>} change
* @param {import("firebase-functions").Change<import('@google-cloud/firestore').DocumentSnapshot<import('../campsites').Garden>>} change
*/
module.exports = async (change) => {
// First replicate the change
await replicate({
change,
tableName: 'campsites',
// Omit legacy props
pick: ['description', 'location', 'facilities', 'listed', 'createTime', 'updateTime', 'photo']
pick: [
'description',
'location',
'facilities',
'listed',
'createTime',
'updateTime',
'photo',
'latestListedChangeAt',
'latestRemovedAt',
'latestWarningForInactivityAt'
]
});
//
// Prepare input for change detection
const { before, after } = change;
let beforeData = null;
let afterData = null;
if (before.exists) {
beforeData = before.data();
}
if (after.exists) {
afterData = after.data();
}
const isCreation = !before.exists;
const isDeletion = !after.exists;
const listedChanged = beforeData?.listed !== afterData?.listed;

// Next, update the listed timestamp in the Firebase doc, but only if it was a document update,
// and only if the listed property changed.
if (isCreation || isDeletion || !listedChanged) {
return;
}

let latestListedChangeAt;
// Did the removal date also change to a defined value? Then use that value for the last change date.
// We guarantee these properties to be equal in this case.
if (
beforeData?.latestRemovedAt !== afterData?.latestRemovedAt &&
afterData?.latestRemovedAt instanceof Timestamp
) {
latestListedChangeAt = afterData.latestRemovedAt;
} else {
latestListedChangeAt = Timestamp.now();
}

// Update the document
// This should result in a new listener call that will just replicate.
await after.ref.update({ latestListedChangeAt });
};
2 changes: 1 addition & 1 deletion api/src/replication/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ exports.createDataMapper = createDataMapper;
* @prop {string} tableName the target table name in Supabase
* @prop {(([key, value]) => [string, any] | [string, any][])} [dataMapper] mapper to map the KV pairs of the source Firestore document,
* it should return a (collection of) equivalent KV pairs compatible with the schema of the Supabase db.
* @prop {Record<string, any>} [extraProps] extra contextual props to add to the inserted document. These are not passed to the mapper, and do not have to be picked.
* @prop {Record<string, any>} [extraProps] extra contextual props to add to the inserted document. These are not passed to the mapper, and do not have to be picked. They will overwrite mapped data.
* @prop {string[]} [pick] subset of Firestore document properties to preserve.
* Does not have to include 'id', since that is taken automatically from the Firebase document ID.
* Must be supplied with values for createTime and updateTime if these internal Firebase properties should be be synced with the SQL table
Expand Down
78 changes: 78 additions & 0 deletions api/test/onCampsitesWrite.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const assert = require('node:assert');
const { Timestamp } = require('firebase-admin/firestore');
const { db } = require('../seeders/app');
const { clearAuth, clearFirestore, wait } = require('./util');
const { createNewUser, createGarden } = require('../seeders/util');

// Note: this behavior requires replication to be "on"
describe('onCampsitesWrite', () => {
let user1;
let campsiteDocRef;

const waitForTriggersTimeout = 5000;

beforeEach(async () => {
// Seed a single test user
user1 = await createNewUser(
{ email: 'user1@slowby.travel' },
{ firstName: 'Bob', lastName: 'Dylan', countryCode: 'US' }
).then((user) =>
createGarden(
{
latitude: 50.952798579681854,
longitude: 4.763172541851901
},
user,
{
description:
'Hello, this is a test garden. If you want to stay here, please send an SMS to 0679669739 or 0681483065.'
}
)
);
campsiteDocRef = db.collection('campsites').doc(user1.uid);
});

const totalTimeout = waitForTriggersTimeout + 2000;

it('auto-updates the listed change time when the user unlists or relists their garden', async () => {
// No listed change should be defined, yet
assert(typeof (await (await campsiteDocRef.get()).data().latestListedChangeAt) === 'undefined');

// Unlist the garden
await campsiteDocRef.update({ listed: false });

// Wait for firestore triggers to pass
await wait(waitForTriggersTimeout);

const { latestListedChangeAt } = (await campsiteDocRef.get()).data();

// Check if a date was set
assert(latestListedChangeAt instanceof Timestamp);

// Re-list
await campsiteDocRef.update({ listed: true });
await wait(waitForTriggersTimeout);

// Check if updated again
const { latestListedChangeAt: secondTimestamp } = (await campsiteDocRef.get()).data();
assert(latestListedChangeAt.valueOf() < secondTimestamp.valueOf());
}).timeout(totalTimeout * 2);

it('has a listed change time exactly equal to the removal time, when manually unlisted', async () => {
// Remove by force
await campsiteDocRef.update({ latestRemovedAt: Timestamp.now(), listed: false });

await wait(waitForTriggersTimeout);

// Check equality
const data = /** @type{import('../../src/lib/types/Garden').Garden} */ (
(await campsiteDocRef.get()).data()
);
assert(data.latestRemovedAt.valueOf() === data.latestListedChangeAt.valueOf());
}).timeout(totalTimeout);

afterEach(async () => {
await clearAuth();
await clearFirestore();
});
});
7 changes: 7 additions & 0 deletions api/test/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ exports.loggerStub = {
debug: logger('debug')
};

exports.wait = (timeout = 1000) =>
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, timeout);
});

exports.clearAuth = async function () {
const deleteURL = `http://127.0.0.1:9099/emulator/v1/projects/${PROJECT_NAME}/accounts`;
return fetch(deleteURL, { method: 'DELETE' });
Expand Down
20 changes: 20 additions & 0 deletions src/lib/types/Garden.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Timestamp } from 'firebase/firestore';

export type Garden = {
/**
* @deprecated Ignore this property, we should eventually remove it.
Expand Down Expand Up @@ -27,6 +29,24 @@ export type Garden = {
| string[];
previousPhotoId?: unknown;
listed: boolean;
/**
* When the listed property boolean garden was last changed.
* Undefined or null when the garden was never changed (after creation it will always be "true").
* This thus shows the last time a garden was unlisted or relisted.
*
* Guaranteed to equal `latestRemovedAt` when the last unlisting was due to removal.
* @since 2024-08-11
*/
latestListedChangeAt?: Timestamp;
/**
* When this campsite was last removed from the map by administration, or inactive cleanup processes.
* @since 2024-08-11
*/
latestRemovedAt?: Timestamp;
/** When the host was last sent an email to remind them that being active is important.
* @since 2024-08-11
*/
latestWarningForInactivityAt?: Timestamp;
/**
* @deprecated A property lingering around from the starting days of WTMG, we should remove it.
* Gardens were first part of a Google Form/Sheet, which was displayed on a uMap.
Expand Down

0 comments on commit 23da3d3

Please sign in to comment.