feat(pairing): solo sealed answers that unlock on pairing

Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 19:34:42 +01:00
1 parent f22d4a3649
commit 09255da7db
26 files changed
+510 -75

No files matched your search

@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
import { ApiError, apiClient } from "@/lib/apiClient";
import { useAuthStore } from "@/store";
import * as swipeService from "@/features/swipe/services/swipeService";
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
@@ -15,12 +16,13 @@ jest.mock("expo-router", () => ({
// Stable identities: the hook's effects key off these, so fresh objects per render
// would retrigger the fetch effect forever and the screen would never settle.
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" }, sealedCount: 0 };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const packState = { ...packs, invalidatePacks: jest.fn() };
const responses = { invalidateResponses: jest.fn() };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
useAuthStore.setState = jest.fn();
const usePacksStore = () => packs;
usePacksStore.getState = () => packState;
const useResponsesStore = () => responses;
@@ -146,6 +148,19 @@ describe("useSwipeScreen daily limit", () => {
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
});
it("updates the sealed answer count optimistically when the server reports one", async () => {
// A solo answer has no partner to match against, so sealed_count is the
// only feedback the server gives back; the banner reads it from auth state.
const { result } = await renderReady();
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null, sealed_count: 3 });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(useAuthStore.setState).toHaveBeenCalledWith({ sealedCount: 3 });
});
it("refreshes the shared streak once the answer lands", async () => {
const { result } = await renderReady();
mockFetchStreak.mockClear();
@@ -0,0 +1,22 @@
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
describe("buildInviteShareMessage", () => {
it("leads with a plain invite when there are no sealed answers", () => {
const message = buildInviteShareMessage("ABCD1234", 0);
expect(message).toBe(
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
});
it("leads with earned value and singular wording for exactly one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 1);
expect(message).toBe(
"Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
});
it("pluralises for more than one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 5);
expect(message).toContain("I have already answered 5 questions about us.");
});
});
@@ -30,10 +30,15 @@ const profile = {
describe("authStore", () => {
beforeEach(() => {
jest.clearAllMocks();
// fetchUser always fetches couple state now, since it also carries the
// sealed answer count for a user who has no couple yet. Individual tests
// override this when they care about the couple/partner shape.
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
useAuthStore.setState({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
@@ -89,12 +94,12 @@ describe("authStore", () => {
max_intensity: 3,
};
useAuthStore.setState({ user: pairedProfile } as any);
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner });
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner, sealed_count: 3 });
await useAuthStore.getState().fetchCouple();
expect(stateSpy).toHaveBeenCalledWith();
expect(useAuthStore.getState()).toMatchObject({ couple, partner });
expect(useAuthStore.getState()).toMatchObject({ couple, partner, sealedCount: 3 });
});
it("updates activity through the standalone API", async () => {