mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(dares): optional photo/audio proof of completion
The sender can require photo or audio proof when sending any dare, catalogue or custom. The recipient captures and uploads the proof as a new couple-scoped 'dare_proof' media kind and attaches it on submit; the server enforces ownership, kind, couple, and MIME-type match. The proof renders inline on the dare card for the sender to review before confirming completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f22d4a3649
commit
504eddaf68
21 files changed
+668
-28
No files matched your search
@@ -196,6 +196,58 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
||||
});
|
||||
|
||||
async function insertProof(owner: string, coupleId: string | null, mime: string, kind = 'dare_proof') {
|
||||
const id = randomUUID();
|
||||
await pool.query(
|
||||
'insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,$4,$5,$6,100)',
|
||||
[id, owner, coupleId, kind, `${kind}/test/${id}.bin`, mime],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
it('stores the proof requirement and defaults to none', async () => {
|
||||
const plain = await repo.send(sender, { dare_id: freeDare });
|
||||
expect(plain).toMatchObject({ proof_type: 'none', proof_media_id: null });
|
||||
const proofed = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||
expect(proofed.proof_type).toBe('photo');
|
||||
await expect(repo.send(sender, { dare_id: freeDare, proof_type: 'video' as never }))
|
||||
.rejects.toMatchObject({ code: 'invalid_proof_type' });
|
||||
});
|
||||
|
||||
it('requires matching proof media before a proofed dare can be submitted', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
|
||||
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'proof_required' });
|
||||
const audio = await insertProof(recipient, couple, 'audio/mp4');
|
||||
await expect(repo.submit(recipient, dare.id, audio)).rejects.toMatchObject({ code: 'proof_type_mismatch' });
|
||||
|
||||
const photo = await insertProof(recipient, couple, 'image/jpeg');
|
||||
const submitted = await repo.submit(recipient, dare.id, photo);
|
||||
expect(submitted).toMatchObject({ status: 'submitted', proof_media_id: photo });
|
||||
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
|
||||
});
|
||||
|
||||
it('refuses proof media the recipient does not own or that is not dare proof', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
|
||||
const foreign = await insertProof(outsider, otherCouple, 'image/jpeg');
|
||||
await expect(repo.submit(recipient, dare.id, foreign)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||
const wrongKind = await insertProof(recipient, couple, 'image/jpeg', 'chat');
|
||||
await expect(repo.submit(recipient, dare.id, wrongKind)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||
const senderOwned = await insertProof(sender, couple, 'image/jpeg');
|
||||
await expect(repo.submit(recipient, dare.id, senderOwned)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||
});
|
||||
|
||||
it('accepts voluntary proof on dares that did not require any', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
const photo = await insertProof(recipient, couple, 'image/jpeg');
|
||||
const submitted = await repo.submit(recipient, dare.id, photo);
|
||||
expect(submitted).toMatchObject({ status: 'submitted', proof_type: 'none', proof_media_id: photo });
|
||||
});
|
||||
|
||||
it('rejects durations outside the offered presets', async () => {
|
||||
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
||||
});
|
||||
|
||||
@@ -13,10 +13,12 @@ const catalogueDareId = '55555555-5555-4555-8555-555555555555';
|
||||
const dare: SentDare = {
|
||||
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
||||
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
||||
direction: 'outgoing', status: 'pending', sender_notes: null,
|
||||
direction: 'outgoing', status: 'pending', sender_notes: null, proof_type: 'none', proof_media_id: null,
|
||||
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||
};
|
||||
|
||||
const proofMediaId = '77777777-7777-4777-8777-777777777777';
|
||||
|
||||
function setup(overrides: Partial<DaresRepository> = {}) {
|
||||
const repository: DaresRepository = {
|
||||
getCatalog: vi.fn(async () => ({
|
||||
@@ -91,7 +93,7 @@ describe('dare routes', () => {
|
||||
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
||||
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
||||
await post(app, `/v1/dares/${dareId}/submit`);
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, null);
|
||||
await post(app, `/v1/dares/${dareId}/complete`);
|
||||
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
||||
await post(app, `/v1/dares/${dareId}/cancel`);
|
||||
@@ -109,6 +111,22 @@ describe('dare routes', () => {
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
||||
});
|
||||
|
||||
it('accepts a proof requirement on send and rejects unknown ones', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'photo' });
|
||||
expect(response.status).toBe(201);
|
||||
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ proof_type: 'photo' }));
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'video' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('passes proof media through on submit and validates its shape', async () => {
|
||||
const { app, repository } = setup();
|
||||
await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: proofMediaId });
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, proofMediaId);
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: 'not-a-uuid' })).status).toBe(400);
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`, { extra_field: true })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('exposes the duration presets the send sheet offers', async () => {
|
||||
const { app } = setup();
|
||||
const response = await app.request('/v1/dares/durations');
|
||||
|
||||
Reference in new issue
Block a user