diff --git a/functions-testcloud/index.js b/functions-testcloud/index.js index f66e55e..8e47293 100644 --- a/functions-testcloud/index.js +++ b/functions-testcloud/index.js @@ -7,7 +7,8 @@ * effects outside Firestore (Firebase Auth updateUser in the two profile * callables); 5 more on 2026-10-01 for places and meetups, audited the same * way (no secrets, no network calls, no schedules, Firestore only); 2 more on - * 2026-10-14 for leaving and cancelling a meetup, audited the same way. Nothing + * 2026-10-14 for leaving and cancelling a meetup, and 1 that day for a pet's + * new name or photo reaching its copies, audited the same way. Nothing * that binds a secret, runs on a schedule, or deletes media belongs here; the * guards below refuse the first two. * @@ -125,6 +126,12 @@ const EXPORTS = { // schedule; both only read and write Firestore. onParticipantDeleted: meetups.onParticipantDeleted, onMeetupUpdated: notifications.onMeetupUpdated, + + // Authorized by the owner on 2026-10-14 with the change that adds it: a + // pet's new name or photo written onto its posts, follow lists, meetup + // rosters and check-ins. No secret, no call out, no schedule; Firestore + // only. + onPetUpdated: pets.onPetUpdated, }; // The test project never resolves to production's Cloudinary account. With @@ -150,7 +157,7 @@ if (testAccount) { // Exactly the authorized set: a name added here by accident is a deployment // nobody approved. -const EXPECTED_COUNT = 41 + (testAccount ? MEDIA_FUNCTIONS.length : 0); +const EXPECTED_COUNT = 42 + (testAccount ? MEDIA_FUNCTIONS.length : 0); if (Object.keys(EXPORTS).length !== EXPECTED_COUNT) { throw new Error(`testcloud entry: ${Object.keys(EXPORTS).length} exports, ${EXPECTED_COUNT} authorized`); } diff --git a/functions/src/__tests__/pet-name-sync.test.ts b/functions/src/__tests__/pet-name-sync.test.ts new file mode 100644 index 0000000..c9e5fe7 --- /dev/null +++ b/functions/src/__tests__/pet-name-sync.test.ts @@ -0,0 +1,137 @@ +import "./setup"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { db } from "../platform"; +import { onPetUpdated } from "../pets"; +import { captureSnapshot, deliverWritten, newEventId, purge } from "./helpers"; + +// A pet's name and photo are copied onto its posts, its followers' follow +// lists, meetup rosters and check-ins, and the apps show those copies. A pet +// renamed or given a new photo went on showing the old ones everywhere but its +// own page. onPetUpdated now writes the pet's current name and photo onto +// every copy. + +const PET_ID = "sync-pet"; +const PET = `pets/${PET_ID}`; +const OWNER = "sync-owner"; +const FOLLOWER = "sync-follower"; +const POST = "posts/sync-post"; +const OTHER_POST = "posts/sync-other-post"; +const FOLLOW = `users/${FOLLOWER}/followingPets/${PET_ID}`; +const ENTRY = `meetups/sync-meetup/participants/${OWNER}`; +const CHECKIN = `locations/sync-place/checkins/${OWNER}_2026-10-14`; +const OLD_PHOTO = "https://res.cloudinary.com/demo/image/upload/old.jpg"; +const NEW_PHOTO = "https://res.cloudinary.com/demo/image/upload/new.jpg"; + +type Method = (...args: unknown[]) => Promise; +const refProto = () => Object.getPrototypeOf(db.doc(PET)) as Record<"update", Method>; + +async function wipe() { + await purge(PET, POST, OTHER_POST, `users/${FOLLOWER}`, "meetups/sync-meetup", "locations/sync-place"); +} + +async function seed() { + await db.doc(PET).set({ name: "Mochi", avatarUrl: OLD_PHOTO, ownerId: OWNER, primaryOwnerId: OWNER, followerCount: 1, postCount: 1 }); + await db.doc(`${PET}/family/${OWNER}`).set({ userId: OWNER, role: "primary" }); + await db.doc(POST).set({ authorId: OWNER, petId: PET_ID, petName: "Mochi", petAvatarUrl: OLD_PHOTO, text: "a post" }); + await db.doc(OTHER_POST).set({ authorId: OWNER, petId: "another-pet", petName: "Other", petAvatarUrl: OLD_PHOTO, text: "another" }); + await db.doc(FOLLOW).set({ petId: PET_ID, petName: "Mochi", petAvatar: OLD_PHOTO, counted: true }); + await db.doc(ENTRY).set({ meetupId: "sync-meetup", userId: OWNER, petId: PET_ID, petName: "Mochi", petAvatar: OLD_PHOTO }); + await db.doc(CHECKIN).set({ userId: OWNER, petId: PET_ID, petName: "Mochi" }); +} + +/** Changes the pet, then delivers that change's event the way Firestore would. */ +async function change(fields: Record) { + const before = await captureSnapshot(PET); + await db.doc(PET).update(fields); + const after = await captureSnapshot(PET); + await deliverWritten(onPetUpdated, before, after, { petId: PET_ID }, newEventId("pet")); +} + +async function copies() { + const [post, follow, entry, checkin, other] = await Promise.all( + [POST, FOLLOW, ENTRY, CHECKIN, OTHER_POST].map(async (path) => (await db.doc(path).get()).data()) + ); + return { post, follow, entry, checkin, other }; +} + +beforeEach(async () => { + await wipe(); + await seed(); +}); +afterEach(() => vi.restoreAllMocks()); +afterAll(wipe); + +describe("a pet's name and photo, wherever they are copied", () => { + it("are the new ones on its posts, follow lists, meetup rosters and check-ins", async () => { + await change({ name: "Mochi Jr", avatarUrl: NEW_PHOTO }); + + const c = await copies(); + expect([c.post?.petName, c.post?.petAvatarUrl]).toEqual(["Mochi Jr", NEW_PHOTO]); + expect([c.follow?.petName, c.follow?.petAvatar]).toEqual(["Mochi Jr", NEW_PHOTO]); + expect([c.entry?.petName, c.entry?.petAvatar]).toEqual(["Mochi Jr", NEW_PHOTO]); + expect(c.checkin?.petName).toBe("Mochi Jr"); + expect([c.other?.petName, c.other?.petAvatarUrl], "another pet's post changed").toEqual(["Other", OLD_PHOTO]); + }); + + it("are not written when only the pet's counts change", async () => { + const before = await captureSnapshot(PET); + await db.doc(PET).update({ followerCount: 2 }); + const after = await captureSnapshot(PET); + const update = vi.spyOn(refProto(), "update"); + + await deliverWritten(onPetUpdated, before, after, { petId: PET_ID }, newEventId("pet")); + + expect(update).not.toHaveBeenCalled(); + }); + + it("end with the latest name when an earlier change is copied last", async () => { + const first = await captureSnapshot(PET); + await db.doc(PET).update({ name: "Mochi Jr" }); + const second = await captureSnapshot(PET); + await db.doc(PET).update({ name: "Mochi the Third" }); + const third = await captureSnapshot(PET); + + await deliverWritten(onPetUpdated, second, third, { petId: PET_ID }, newEventId("pet")); + await deliverWritten(onPetUpdated, first, second, { petId: PET_ID }, newEventId("pet")); + + const c = await copies(); + expect([c.post?.petName, c.follow?.petName, c.entry?.petName, c.checkin?.petName]).toEqual(Array(4).fill("Mochi the Third")); + }); + + it("end with the latest name when the pet is renamed again while they are written", async () => { + // As soon as the first copy is written, the pet is renamed once more, and + // that rename's own event is never delivered here: the sync has to notice. + const proto = refProto(); + const realUpdate = proto.update; + let renamed = false; + vi.spyOn(proto, "update").mockImplementation(async function (this: { path: string }, ...args: unknown[]) { + if (!renamed && this.path !== PET) { + renamed = true; + await realUpdate.call(db.doc(PET), { name: "Mochi the Third" }); + } + return realUpdate.apply(this, args); + }); + + await change({ name: "Mochi Jr" }); + + expect(renamed).toBe(true); + const c = await copies(); + expect([c.post?.petName, c.follow?.petName, c.entry?.petName, c.checkin?.petName]).toEqual(Array(4).fill("Mochi the Third")); + }); + + it("leave out a follow undone while they are written, and write the rest", async () => { + // The follower unfollows just as their copy is about to be written. + const proto = refProto(); + const realUpdate = proto.update; + vi.spyOn(proto, "update").mockImplementation(async function (this: { path: string }, ...args: unknown[]) { + if (this.path === FOLLOW) await db.doc(FOLLOW).delete(); + return realUpdate.apply(this, args); + }); + + await change({ name: "Mochi Jr" }); + + expect((await db.doc(FOLLOW).get()).exists, "the undone follow was written back").toBe(false); + const c = await copies(); + expect([c.post?.petName, c.entry?.petName, c.checkin?.petName]).toEqual(Array(3).fill("Mochi Jr")); + }); +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index b20b4c5..0d48ca3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -70,6 +70,7 @@ export { createPetCallable, deletePetCallable, followPetCallable, + onPetUpdated, resumeAbandonedPetDeletions, unfollowPetCallable, updatePetCallable, diff --git a/functions/src/pets.ts b/functions/src/pets.ts index 95147bd..305e37c 100644 --- a/functions/src/pets.ts +++ b/functions/src/pets.ts @@ -1,10 +1,12 @@ +import { onDocumentUpdated } from "firebase-functions/v2/firestore"; import { onCall, HttpsError } from "firebase-functions/v2/https"; import { onSchedule } from "firebase-functions/v2/scheduler"; -import { admin, db, FieldValue, Timestamp } from "./platform"; +import { admin, db, FieldPath, FieldValue, Timestamp } from "./platform"; import { cascadeDeletePet } from "./cleanup"; import { assertCallerAccountActive, getNotificationActor } from "./notifications"; import { assertRateLimit, + FIRESTORE_BATCH_LIMIT, getDefaultAvatar, optionalTrimmedString, optionalTrustedHttpsUrl, @@ -743,3 +745,139 @@ export const unfollowPetCallable = onCall(async (request) => { } return { success: true }; }); + +/** + * Keeps the copies of a pet's name and photo in step with the pet. + * + * Posts, follow lists, meetup rosters and check-ins each keep the pet's name, + * and all but check-ins its photo, as they were when written, and the apps + * show those copies. Without this, a pet renamed or given a new photo went on + * showing the old ones everywhere but its own page. + */ +export const onPetUpdated = onDocumentUpdated( + { document: "pets/{petId}", timeoutSeconds: 540, memory: "512MiB" }, + async (event) => { + const before = event.data?.before.data(); + const after = event.data?.after.data(); + if (!before || !after) return; + // Most writes to a pet are its counts. Only a new name or photo has + // anything to copy. + if (before.name === after.name && before.avatarUrl === after.avatarUrl) return; + await syncPetCopies(event.params.petId); + } +); + +/** + * Writes the pet's current name and photo onto every copy, then looks at the + * pet again. Two changes in quick succession can be copied at the same time, + * or the earlier one last; whichever finishes finds what it wrote out of date + * and goes round again. The values always come from the pet as it is now, + * never from the event. + */ +export async function syncPetCopies(petId: string): Promise { + const petRef = db.doc(`pets/${petId}`); + let pet = await petRef.get(); + for (let pass = 0; pass < 5 && pet.exists; pass += 1) { + const data = pet.data() ?? {}; + const { name, avatar } = petIdentity(petId, data); + await Promise.all([ + updateCopies( + db.collection("posts").where("petId", "==", petId), + { petName: name, petAvatarUrl: avatar } + ), + updateCopies( + db.collectionGroup("followingPets").where("petId", "==", petId), + { petName: name, petAvatar: avatar } + ), + updateRosterCopies(petId, data, { petName: name, petAvatar: avatar }), + updateCopies( + db.collectionGroup("checkins").where("petId", "==", petId), + { petName: name } + ), + ]); + pet = await petRef.get(); + const now = petIdentity(petId, pet.data() ?? {}); + if (now.name === name && now.avatar === avatar) return; + } +} + +/** The name and photo a copy carries, the way the callables write them. */ +function petIdentity( + petId: string, + pet: admin.firestore.DocumentData +): { name: string; avatar: string } { + return { + name: typeof pet.name === "string" && pet.name.trim().length > 0 ? pet.name : "Pet", + avatar: + typeof pet.avatarUrl === "string" && pet.avatarUrl.trim().length > 0 + ? pet.avatarUrl + : getDefaultAvatar(petId), + }; +} + +/** Every document the query finds, a page at a time. */ +async function updateCopies( + query: admin.firestore.Query, + fields: Record +): Promise { + const ordered = query.orderBy(FieldPath.documentId()); + let last: admin.firestore.QueryDocumentSnapshot | null = null; + for (;;) { + const page: admin.firestore.QuerySnapshot = await (last ? ordered.startAfter(last) : ordered) + .limit(FIRESTORE_BATCH_LIMIT) + .get(); + if (page.empty) return; + await updateEach(page.docs, fields); + last = page.docs[page.docs.length - 1]; + if (page.size < FIRESTORE_BATCH_LIMIT) return; + } +} + +/** + * Meetup roster entries name the person and the pet they came with, and the + * index there is on the person, so the pet's entries are found through its + * owners. An entry by someone who has since left the pet's family keeps the + * name it was written with. + */ +async function updateRosterCopies( + petId: string, + pet: admin.firestore.DocumentData, + fields: Record +): Promise { + const family = await db.collection(`pets/${petId}/family`).limit(PET_FAMILY_READ_LIMIT).get(); + const owners = new Set(family.docs.map((docSnap) => docSnap.id)); + for (const field of ["ownerId", "primaryOwnerId"]) { + if (typeof pet[field] === "string" && pet[field]) owners.add(pet[field]); + } + const uids = Array.from(owners); + for (let i = 0; i < uids.length; i += 30) { + const entries = await db + .collectionGroup("participants") + .where("userId", "in", uids.slice(i, i + 30)) + .get(); + await updateEach(entries.docs.filter((docSnap) => docSnap.data().petId === petId), fields); + } +} + +/** + * A hundred at a time, skipping a copy that already says it, and leaving out + * one that went while the copies were being written: a follow undone, a + * roster entry left. update() so a copy that is gone stays gone. + */ +async function updateEach( + docs: admin.firestore.QueryDocumentSnapshot[], + fields: Record +): Promise { + const stale = docs.filter((docSnap) => + Object.entries(fields).some(([key, value]) => docSnap.get(key) !== value) + ); + for (let i = 0; i < stale.length; i += 100) { + await Promise.all( + stale.slice(i, i + 100).map((docSnap) => + docSnap.ref.update(fields).catch((error: unknown) => { + if ((error as { code?: unknown })?.code !== 5) throw error; + }) + ) + ); + } +}