feat: iphone app
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# Firebase Haptika
|
||||
|
||||
Projet `haptika-d13d3`, Firestore `(default)`, région existante `eur3`.
|
||||
|
||||
## En service
|
||||
|
||||
- Règles et trois index composites déployés : mesures par source, séances par pratique/statut.
|
||||
- Applications natives iOS et Android `com.haptika.mobile.dev` enregistrées, configuration embarquée dans Expo. Aucun secret administrateur embarqué.
|
||||
- Auth e-mail/mot de passe (déjà activée dans le projet), création de compte, connexion, réinitialisation, déconnexion.
|
||||
- Profil, choix d'équipement, fin d'onboarding et préférence écran noir synchronisés. File persistante séparée par UID pour les modifications de ces champs. Les champs non mappés restent locaux.
|
||||
- Acquisition immutable et séances versionnées : contrats Firestore et repositories prêts, sans import des résultats Figma.
|
||||
- Mode local sans compte ; aucune migration implicite des anciennes données vers un compte.
|
||||
|
||||
`users` et ses sous-collections apparaissent lorsqu'un utilisateur crée son compte ou enregistre des données. Aucun faux utilisateur ni historique n'est prérempli.
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
npm ci --prefix firebase
|
||||
npm ci --prefix firebase/functions
|
||||
npm test --prefix firebase
|
||||
npm run typecheck --prefix mobile
|
||||
npm test --prefix mobile
|
||||
```
|
||||
|
||||
Java 17 et Node 22+ sont nécessaires pour cette version de l'émulateur. Les tests utilisent exclusivement `demo-haptika`, port 8085. Dix tests couvrent droits d'accès, schémas, timestamps, révisions, mesures immuables, suppression et rejeux. Les tests mobiles couvrent aussi le mapping excluant les valeurs de maquette.
|
||||
|
||||
Un smoke test ponctuel sur le vrai projet a vérifié Auth, création/modification/lecture du profil et refus d'écriture des abonnements. Le compte temporaire et son profil ont été supprimés après le test.
|
||||
|
||||
Le development build iOS a compilé et a été installé sur le simulateur iPhone 16 Pro (iOS 18.6). Le lancement, l’écran de compte, le refus d’identifiants invalides et le basculement connexion/création ont été vérifiés dans le simulateur. Android est configuré, mais son build n’a pas été testé. La connexion positive native avec un vrai compte utilisateur reste à effectuer.
|
||||
|
||||
## Déploiement
|
||||
|
||||
```sh
|
||||
npx firebase-tools deploy --only firestore --project haptika-d13d3
|
||||
```
|
||||
|
||||
Les fonctions `deleteEntity` et `deleteAccount` sont dans `functions/index.js`. Leur code est préparé ; `deleteEntity` est testé avec l'émulateur. Elles ne sont **pas déployées**, car la facturation du projet est désactivée. Après activation de Blaze par le propriétaire :
|
||||
|
||||
```sh
|
||||
npx firebase-tools deploy --only functions:haptika --project haptika-d13d3
|
||||
```
|
||||
|
||||
Ne pas ouvrir les écritures client sur les tombstones pour contourner cette limite. Les fonctions s'exécutent en `europe-west1`. `deleteAccount` exige une authentification récente, bloque les écritures, purge les sous-collections, révoque les jetons et supprime Auth. Il garde un marqueur minimal `deleting` sans profil pour bloquer les anciens ID tokens. La suppression doit être testée avec Auth Emulator avant d'exposer son bouton dans l'app.
|
||||
|
||||
## À brancher ensuite
|
||||
|
||||
- Pont WatchConnectivity dans Expo et association au bon compte. La Watch existante est encore liée au compagnon natif historique ; elle ne synchronise pas encore avec ce projet.
|
||||
- Capteurs réels, historique et graphiques : utiliser les repositories, jamais les valeurs des frames. Le repository de mesure garde l'UUID original. Celui de séance exige une révision attendue ; ces transactions nécessitent le réseau et leur file métier Watch reste à construire.
|
||||
- Validation des achats et écriture serveur des droits : aucun plan payant n'est accordé par le choix du parcours Watch. Pas de validation d'achat fictive.
|
||||
- Agrégats et baselines : réservés au serveur, sans calcul déployé pour l'instant.
|
||||
- Rappels/dispositifs : collections réservées en lecture propriétaire ; écritures fermées jusqu'au branchement de leur contrat et des capacités natives.
|
||||
- Paramètres avancés : les réglages par pratique ont un contrat minimal, mais les clés de sliders Figma ne sont pas téléversées automatiquement.
|
||||
|
||||
La maquette conserve ses exemples de santé et d'abonnement. Ils ne prouvent ni un abonnement réel ni une connexion matérielle. Le nouvel écran de compte affiche le véritable état de synchronisation du profil.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"indexes": [
|
||||
{
|
||||
"collectionGroup": "measurements",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "startedAt",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "sessions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "practiceId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "startedAt",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "sessions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "startedAt",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": []
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
function owner(uid) { return request.auth != null && request.auth.uid == uid; }
|
||||
function active(uid) { return owner(uid) && get(/databases/$(database)/documents/users/$(uid)).data.accountState == 'active'; }
|
||||
function bounded(s, n) { return s is string && s.size() <= n; }
|
||||
function unit(n) { return n is number && n >= 0 && n <= 1; }
|
||||
function nullableNumber(n, max) { return n == null || (n is number && n >= 0 && n <= max); }
|
||||
function feeling(n) { return n == null || (n is int && n >= 1 && n <= 5); }
|
||||
function source(s) { return s in ['iphoneCameraPPG', 'appleWatchOptical']; }
|
||||
function stamp(d) {
|
||||
return d.schemaVersion == 1 && d.updatedAt == request.time &&
|
||||
(resource == null ? d.createdAt == request.time : d.createdAt == resource.data.createdAt);
|
||||
}
|
||||
function revision(d) { return d.revision is int && (resource == null ? d.revision == 1 : d.revision == resource.data.revision + 1); }
|
||||
function alive(uid, kind, id) { return !exists(/databases/$(database)/documents/users/$(uid)/tombstones/$(kind + '_' + id)); }
|
||||
function profile(d) {
|
||||
return d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','accountState','firstName','birthYear','gender','locale','timeZone']) &&
|
||||
stamp(d) && d.accountState == 'active' && bounded(d.firstName,50) &&
|
||||
(d.birthYear == null || (d.birthYear is int && d.birthYear >= 1900 && d.birthYear <= request.time.year())) &&
|
||||
d.gender in [null,'female','male'] && bounded(d.locale,20) && bounded(d.timeZone,80);
|
||||
}
|
||||
match /users/{uid} {
|
||||
allow read: if owner(uid);
|
||||
allow create: if owner(uid) && profile(request.resource.data);
|
||||
allow update: if active(uid) && profile(request.resource.data);
|
||||
match /onboarding/{id} {
|
||||
allow read: if active(uid);
|
||||
allow create, update: if active(uid) && id == 'current' && stamp(request.resource.data) &&
|
||||
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','stress','sleep','energy','experience','completed','flowVersion']) &&
|
||||
request.resource.data.stress in [null,'low','medium','high'] && request.resource.data.sleep in [null,'low','medium','high'] &&
|
||||
request.resource.data.energy in [null,'low','medium','high'] && request.resource.data.experience in ['phone','watch'] &&
|
||||
request.resource.data.completed is bool && request.resource.data.flowVersion == 1;
|
||||
}
|
||||
match /preferences/{id} {
|
||||
allow read: if active(uid);
|
||||
allow create, update: if active(uid) && id == 'general' && stamp(request.resource.data) &&
|
||||
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','notificationsEnabled','saveMindAndBodyWorkouts','blackScreenEnabled']) &&
|
||||
request.resource.data.notificationsEnabled is bool && request.resource.data.saveMindAndBodyWorkouts is bool && request.resource.data.blackScreenEnabled is bool;
|
||||
}
|
||||
match /practiceSettings/{id} {
|
||||
allow read: if active(uid);
|
||||
allow create, update: if active(uid) && id in ['breathing','anchoring','meditation','sleep','wake','coherence'] && stamp(request.resource.data) &&
|
||||
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','durationSeconds','intensity','soundEnabled']) &&
|
||||
request.resource.data.durationSeconds is int && request.resource.data.durationSeconds >= 30 && request.resource.data.durationSeconds <= 7200 &&
|
||||
unit(request.resource.data.intensity) && request.resource.data.soundEnabled is bool;
|
||||
}
|
||||
match /measurements/{id} {
|
||||
function measurement(d) {
|
||||
return stamp(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','source','deviceId','startedAt','endedAt','durationSeconds','averageBpm','minimumBpm','maximumBpm','estimatedRmssdMs','sdnnMs','quality','coverage','valid','algorithmVersion']) &&
|
||||
source(d.source) && bounded(d.deviceId,100) && d.startedAt is timestamp && d.endedAt is timestamp &&
|
||||
d.startedAt <= d.endedAt && d.endedAt <= request.time && d.durationSeconds is number && d.durationSeconds > 0 && d.durationSeconds <= 7200 &&
|
||||
nullableNumber(d.averageBpm,300) && nullableNumber(d.minimumBpm,300) && nullableNumber(d.maximumBpm,300) &&
|
||||
nullableNumber(d.estimatedRmssdMs,5000) && nullableNumber(d.sdnnMs,5000) && unit(d.quality) && unit(d.coverage) && d.valid is bool && bounded(d.algorithmVersion,80);
|
||||
}
|
||||
allow read: if active(uid);
|
||||
allow create: if active(uid) && alive(uid,'measurement',id) && measurement(request.resource.data);
|
||||
}
|
||||
match /measurementAnnotations/{id} {
|
||||
allow read: if active(uid);
|
||||
allow create, update: if active(uid) && alive(uid,'measurement',id) && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id)) &&
|
||||
stamp(request.resource.data) && revision(request.resource.data) &&
|
||||
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','feeling','posture','atRest','recentExercise','recentCaffeine','recentMeal','recordedAt']) &&
|
||||
feeling(request.resource.data.feeling) && request.resource.data.posture in [null,'seated','standing','lying','unknown'] &&
|
||||
request.resource.data.atRest is bool && request.resource.data.recentExercise is bool && request.resource.data.recentCaffeine is bool && request.resource.data.recentMeal is bool &&
|
||||
request.resource.data.recordedAt is timestamp && request.resource.data.recordedAt <= request.time;
|
||||
}
|
||||
match /sessions/{id} {
|
||||
function refValid(id) { return id == null || (id is string && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id))); }
|
||||
function session(d) {
|
||||
return stamp(d) && revision(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','practiceId','protocolVersion','deviceId','source','startedAt','endedAt','plannedDurationSeconds','actualDurationSeconds','status','beforeMeasurementId','afterMeasurementId','feelingBefore','feelingAfter']) &&
|
||||
d.practiceId in ['breathing','anchoring','meditation','sleep','wake','coherence'] && d.protocolVersion is int && d.protocolVersion >= 1 &&
|
||||
bounded(d.deviceId,100) && source(d.source) && d.startedAt is timestamp && d.startedAt <= request.time &&
|
||||
(d.endedAt == null || (d.endedAt is timestamp && d.endedAt >= d.startedAt && d.endedAt <= request.time)) &&
|
||||
d.plannedDurationSeconds is int && d.plannedDurationSeconds > 0 && d.plannedDurationSeconds <= 7200 &&
|
||||
d.actualDurationSeconds is number && d.actualDurationSeconds >= 0 && d.actualDurationSeconds <= 86400 &&
|
||||
d.status in ['inProgress','awaitingAfter','completed','cancelled'] &&
|
||||
(d.status == 'inProgress' || d.endedAt != null) && refValid(d.beforeMeasurementId) && refValid(d.afterMeasurementId) &&
|
||||
feeling(d.feelingBefore) && feeling(d.feelingAfter);
|
||||
}
|
||||
allow read: if active(uid);
|
||||
allow create: if active(uid) && alive(uid,'session',id) && session(request.resource.data);
|
||||
allow update: if active(uid) && alive(uid,'session',id) && session(request.resource.data) &&
|
||||
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['updatedAt','revision','endedAt','actualDurationSeconds','status','afterMeasurementId','feelingBefore','feelingAfter']) &&
|
||||
(resource.data.status == 'inProgress' || (resource.data.status == 'awaitingAfter' && request.resource.data.status in ['awaitingAfter','completed','cancelled']));
|
||||
}
|
||||
// Reserved server-owned collections. Unimplemented client contracts stay closed.
|
||||
match /{collection}/{id} {
|
||||
allow read: if active(uid) && collection in ['devices','reminders','dailyStats','baselines','entitlements','tombstones'];
|
||||
}
|
||||
}
|
||||
match /protocols/{id}/versions/{version} { allow read: if request.auth != null && resource.data.published == true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const {initializeApp}=require('firebase-admin/app');
|
||||
const {getFirestore,FieldValue}=require('firebase-admin/firestore');
|
||||
const {getAuth}=require('firebase-admin/auth');
|
||||
const {onCall,HttpsError}=require('firebase-functions/v2/https');
|
||||
initializeApp();
|
||||
const db=getFirestore();
|
||||
const options={region:'europe-west1',maxInstances:3};
|
||||
function uidOf(request){if(!request.auth)throw new HttpsError('unauthenticated','Connexion requise.');return request.auth.uid;}
|
||||
exports.deleteEntity=onCall(options,async request=>{
|
||||
const uid=uidOf(request),{kind,id}=request.data??{};
|
||||
if(!['measurement','session'].includes(kind)||typeof id!=='string'||!/^[a-zA-Z0-9-]{1,100}$/.test(id))throw new HttpsError('invalid-argument','Entité invalide.');
|
||||
const user=db.doc(`users/${uid}`),ref=user.collection(kind==='measurement'?'measurements':'sessions').doc(id);
|
||||
// Tombstone and deletion are atomic. It remains even if cleanup below retries.
|
||||
await db.runTransaction(async tx=>{const profile=await tx.get(user);if(profile.data()?.accountState!=='active')throw new HttpsError('failed-precondition','Compte indisponible.');const tombstone=user.collection('tombstones').doc(`${kind}_${id}`);const existing=await tx.get(tombstone);if(!existing.exists)tx.create(tombstone,{schemaVersion:1,entityId:id,entityKind:kind,createdAt:FieldValue.serverTimestamp(),updatedAt:FieldValue.serverTimestamp()});tx.delete(ref);if(kind==='measurement')tx.delete(user.collection('measurementAnnotations').doc(id));});
|
||||
if(kind==='measurement'){
|
||||
// Bounded batches; repeated calls finish cleanup after a transient failure.
|
||||
for(const field of ['beforeMeasurementId','afterMeasurementId']){
|
||||
let page;
|
||||
do {page=await user.collection('sessions').where(field,'==',id).limit(200).get();if(page.empty)break;const batch=db.batch();for(const d of page.docs)batch.update(d.ref,{[field]:null,revision:FieldValue.increment(1),updatedAt:FieldValue.serverTimestamp()});await batch.commit();}while(!page.empty);
|
||||
}
|
||||
}
|
||||
// Derived data is disposable. Recompute before exposing it again.
|
||||
await db.recursiveDelete(user.collection('dailyStats'));
|
||||
await db.recursiveDelete(user.collection('baselines'));
|
||||
return {deleted:true};
|
||||
});
|
||||
exports.deleteAccount=onCall(options,async request=>{
|
||||
const uid=uidOf(request);
|
||||
if(Date.now()/1000-request.auth.token.auth_time>300)throw new HttpsError('failed-precondition','Reconnecte-toi avant de supprimer ton compte.');
|
||||
const user=db.doc(`users/${uid}`);
|
||||
await user.set({accountState:'deleting',updatedAt:FieldValue.serverTimestamp()},{merge:true});
|
||||
// Keep blocking profile until all child collections have been removed.
|
||||
for(const collection of await user.listCollections())await db.recursiveDelete(collection);
|
||||
await getAuth().revokeRefreshTokens(uid);
|
||||
try{await getAuth().deleteUser(uid);}catch(e){if(e.code!=='auth/user-not-found')throw e;}
|
||||
// Retain a minimal deleting marker to deny old ID-token replay until expiry.
|
||||
// No profile or health information remains in the marker.
|
||||
await user.set({schemaVersion:1,accountState:'deleting',updatedAt:FieldValue.serverTimestamp()});
|
||||
return {deleted:true};
|
||||
});
|
||||
Generated
+2971
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"name":"haptika-server","private":true,"main":"index.js","engines":{"node":"22"},"dependencies":{"firebase-admin":"^13.0.0","firebase-functions":"^6.0.0"}}
|
||||
Generated
+8701
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"name":"haptika-firestore","private":true,"scripts":{"test":"firebase emulators:exec --project demo-haptika --only firestore 'node --test tests/*.test.cjs'"},"devDependencies":{"@firebase/rules-unit-testing":"4.0.1","firebase":"11.10.0","firebase-tools":"13.12.0"}}
|
||||
@@ -0,0 +1,39 @@
|
||||
const {before, after, test} = require('node:test');
|
||||
const {readFileSync} = require('node:fs');
|
||||
const {initializeTestEnvironment, assertSucceeds:ok, assertFails:no} = require('@firebase/rules-unit-testing');
|
||||
const {doc, setDoc, updateDoc, getDoc, deleteDoc, serverTimestamp, Timestamp} = require('firebase/firestore');
|
||||
let env, a, b;
|
||||
const meta=()=>({schemaVersion:1,createdAt:serverTimestamp(),updatedAt:serverTimestamp()});
|
||||
const profile=()=>({...meta(),accountState:'active',firstName:'Test',birthYear:null,gender:null,locale:'fr',timeZone:'Europe/Paris'});
|
||||
const measure=()=>({...meta(),source:'appleWatchOptical',deviceId:'watch-1',startedAt:Timestamp.fromMillis(1000),endedAt:Timestamp.fromMillis(61000),durationSeconds:60,averageBpm:70,minimumBpm:65,maximumBpm:80,estimatedRmssdMs:null,sdnnMs:null,quality:0.9,coverage:1,valid:true,algorithmVersion:'1'});
|
||||
const session=()=>({...meta(),revision:1,practiceId:'breathing',protocolVersion:1,deviceId:'phone-1',source:'iphoneCameraPPG',startedAt:Timestamp.fromMillis(1000),endedAt:null,plannedDurationSeconds:300,actualDurationSeconds:0,status:'inProgress',beforeMeasurementId:null,afterMeasurementId:null,feelingBefore:null,feelingAfter:null});
|
||||
before(async()=>{env=await initializeTestEnvironment({projectId:'demo-haptika',firestore:{host:'127.0.0.1',port:8085,rules:readFileSync('firestore.rules','utf8')}});a=env.authenticatedContext('a').firestore();b=env.authenticatedContext('b').firestore();await setDoc(doc(a,'users/a'),profile());await setDoc(doc(b,'users/b'),profile());});
|
||||
after(async()=>env?.cleanup());
|
||||
test('authentication and ownership',async()=>{await no(getDoc(doc(env.unauthenticatedContext().firestore(),'users/a')));await no(getDoc(doc(b,'users/a')));await no(setDoc(doc(b,'users/a/measurements/cross'),measure()));await ok(getDoc(doc(a,'users/a')));});
|
||||
test('profile rejects privilege and invalid fields',async()=>{for(const patch of [{plan:'premium'},{birthYear:3000},{accountState:'deleting'}])await no(updateDoc(doc(a,'users/a'),{...patch,updatedAt:serverTimestamp()}));});
|
||||
test('measurements immutable and validated',async()=>{const ref=doc(a,'users/a/measurements/valid');await ok(setDoc(ref,measure()));await no(updateDoc(ref,{averageBpm:95,updatedAt:serverTimestamp()}));await no(deleteDoc(ref));for(const [id,patch] of [['bad',{quality:2}],['time',{updatedAt:Timestamp.fromMillis(0)}],['raw',{samples:[70]}]])await no(setDoc(doc(a,`users/a/measurements/${id}`),{...measure(),...patch}));});
|
||||
test('client cannot grant server-owned data',async()=>{for(const c of ['entitlements','baselines','dailyStats','tombstones'])await no(setDoc(doc(a,`users/a/${c}/current`),{plan:'premium'}));await no(setDoc(doc(a,'protocols/breathing/versions/1'),{published:true}));});
|
||||
test('settings closed schema',async()=>{const p={...meta(),notificationsEnabled:false,saveMindAndBodyWorkouts:false,blackScreenEnabled:true};await ok(setDoc(doc(a,'users/a/preferences/general'),p));await no(setDoc(doc(a,'users/a/preferences/other'),p));await no(setDoc(doc(a,'users/a/practiceSettings/breathing'),{...meta(),durationSeconds:0,intensity:0.5,soundEnabled:false}));});
|
||||
test('session revision, references and terminal state',async()=>{const ref=doc(a,'users/a/sessions/s1');await ok(setDoc(ref,session()));await no(updateDoc(ref,{revision:3,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:2,afterMeasurementId:'missing',updatedAt:serverTimestamp()}));await ok(updateDoc(ref,{revision:2,status:'completed',endedAt:Timestamp.fromMillis(301000),actualDurationSeconds:300,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:3,status:'inProgress',updatedAt:serverTimestamp()}));});
|
||||
test('annotations require parent, revision and feeling',async()=>{const v={...meta(),revision:1,feeling:3,posture:'seated',atRest:true,recentExercise:false,recentCaffeine:false,recentMeal:false,recordedAt:Timestamp.fromMillis(61000)};await no(setDoc(doc(a,'users/a/measurementAnnotations/missing'),v));await setDoc(doc(a,'users/a/measurements/annotated'),measure());await ok(setDoc(doc(a,'users/a/measurementAnnotations/annotated'),v));await no(updateDoc(doc(a,'users/a/measurementAnnotations/annotated'),{feeling:6,revision:2,updatedAt:serverTimestamp()}));});
|
||||
test('tombstones block resurrection',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/a/tombstones/measurement_deleted'),{deletedAt:Timestamp.now()}));await no(setDoc(doc(a,'users/a/measurements/deleted'),measure()));});
|
||||
test('deleting account is blocked',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/deleting'),{...profile(),accountState:'deleting'}));const db=env.authenticatedContext('deleting').firestore();await no(setDoc(doc(db,'users/deleting/measurements/m'),measure()));await no(updateDoc(doc(db,'users/deleting'),{accountState:'active',updatedAt:serverTimestamp()}));});
|
||||
test('server deletion is idempotent and clears same-user references',async()=>{
|
||||
process.env.GCLOUD_PROJECT='demo-haptika';process.env.FIRESTORE_EMULATOR_HOST='127.0.0.1:8085';
|
||||
const {deleteEntity}=require('../functions');
|
||||
await env.withSecurityRulesDisabled(async c=>{
|
||||
const db=c.firestore();await setDoc(doc(db,'users/delete-test'),{...profile()});
|
||||
await setDoc(doc(db,'users/delete-test/measurements/target'),measure());
|
||||
await setDoc(doc(db,'users/delete-test/measurementAnnotations/target'),{feeling:3});
|
||||
await setDoc(doc(db,'users/delete-test/sessions/linked'),{...session(),beforeMeasurementId:'target'});
|
||||
});
|
||||
const request={auth:{uid:'delete-test',token:{}},data:{kind:'measurement',id:'target'}};
|
||||
await deleteEntity.run(request);await deleteEntity.run(request);
|
||||
await env.withSecurityRulesDisabled(async c=>{
|
||||
const db=c.firestore();require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurements/target'))).exists(),false);
|
||||
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurementAnnotations/target'))).exists(),false);
|
||||
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/tombstones/measurement_target'))).exists(),true);
|
||||
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/sessions/linked'))).data().beforeMeasurementId,null);
|
||||
});
|
||||
await require('node:assert/strict').rejects(deleteEntity.run({data:{kind:'measurement',id:'target'}}),{code:'unauthenticated'});
|
||||
});
|
||||
Reference in New Issue
Block a user