Skip to content
Merged
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
11 changes: 9 additions & 2 deletions functions-testcloud/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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`);
}
Expand Down
137 changes: 137 additions & 0 deletions functions/src/__tests__/pet-name-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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<string, unknown>) {
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"));
});
});
1 change: 1 addition & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export {
createPetCallable,
deletePetCallable,
followPetCallable,
onPetUpdated,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the new trigger in the local acceptance shim

Exporting this trigger does not make it run in the repository's local acceptance environment: functions/scripts/callable-shim.mjs:278-298 manually enumerates every Firestore trigger and has no onPetUpdated entry. Consequently, editing a pet through the shim leaves posts, follows, rosters, and check-ins stale even though the same flow works after deployment, so local client-wiring acceptance cannot exercise the behavior introduced here. Add the pets collection update watcher alongside the export.

Useful? React with 馃憤聽/ 馃憥.

resumeAbandonedPetDeletions,
unfollowPetCallable,
updatePetCallable,
Expand Down
140 changes: 139 additions & 1 deletion functions/src/pets.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<void> {
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 }
Comment on lines +794 to +795

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provision the check-ins index before querying it

When onPetUpdated is deployed to petnote-devtest, every name or photo change executes this collection-group query, but STATUS.md:796-798 records that the test project deliberately does not have the checkins.petId single-field collection-group index. Its existing petId + createdAt DESC composite cannot satisfy this query's document-ID ordering, so the Promise.all rejects with FAILED_PRECONDITION and the trigger retries after potentially updating the other copy types. Provision the required index before exporting this trigger to the test project.

Useful? React with 馃憤聽/ 馃憥.

),
]);
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<string, string>
): Promise<void> {
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<string, string>
): Promise<void> {
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<string, string>
): Promise<void> {
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;
})
)
);
}
}
Loading