feat: evolve MetaBloom into full product foundation
MetaBloom CI / app (push) Canceled after 0s
MetaBloom CI / api (push) Canceled after 0s

This commit is contained in:
AI Company
2026-07-21 19:24:59 +00:00
parent 082cd868d4
commit ea8b5be058
38 changed files with 610 additions and 58 deletions
+28
View File
@@ -0,0 +1,28 @@
name: MetaBloom CI
on:
push:
branches: [main]
pull_request:
jobs:
app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run build:web
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -r server/requirements-dev.txt
- run: pytest -q server/tests
+28 -18
View File
File diff suppressed because one or more lines are too long
+71 -39
View File
@@ -1,33 +1,38 @@
# MetaBloom
Compagnon métabolique moderne, francophone et sans culpabilisation pour aider les personnes concernées par le surpoids, le prédiabète ou le diabète de type 2 à suivre leurs habitudes quotidiennes.
MetaBloom est un compagnon métabolique mobile-first, francophone et non culpabilisant pour les personnes concernées par le surpoids, le prédiabète ou le diabète de type 2. Une seule base Expo cible iOS, Android et le Web.
> **Statut : prototype éducatif et bien-être.** MetaBloom ne diagnostique aucune maladie, ne calcule aucun traitement et ne remplace pas un professionnel de santé. En cas durgence, contactez les services durgence.
> **Statut : produit en développement, usage éducatif et bien-être uniquement.** MetaBloom ne diagnostique pas, ninterprète pas une mesure, ne calcule pas de dose et ne modifie jamais un traitement. En cas durgence, contactez les services durgence.
## Le produit
## Fonctionnalités disponibles
Une seule application mobile-first pour iOS, Android et le web :
- onboarding avec objectif, parcours et consentement informatif ;
- parcours Prévention, Perte de poids, Diabète type 2 et GLP-1 ;
- dashboard quotidien responsive avec données réellement dynamiques ;
- journal enrichi : poids, glycémie facultative, eau, pas, sommeil, repas et humeur ;
- validation des valeurs et gestion mg/dL/mmol/L ;
- graphiques et moyennes calculés sur les sept derniers jours ;
- coaching comportemental et micro-leçons sans recommandations thérapeutiques ;
- préférences, rappels préparés, export CSV et effacement local ;
- persistance hors ligne via AsyncStorage ;
- backend FastAPI/PostgreSQL auto-hébergeable avec JWT, consentement, CRUD, export et suppression de compte ;
- préparation UI pour scan de repas, Apple Santé et Health Connect.
- tableau de bord quotidien avec score souple et petites actions ;
- journal rapide : poids, eau, pas et glycémie manuelle facultative ;
- tendances sur 7 jours et encouragements non culpabilisants ;
- préférences dunités et informations de sécurité ;
- persistance locale sur lappareil, sans compte ni serveur pour ce MVP ;
- interface responsive, accessible et utilisable au clavier.
## Captures
## Pourquoi MetaBloom ?
Le marché combine trois familles : suivi nutritionnel (MyFitnessPal), coaching comportemental (Noom/WW) et journal du diabète (mySugr). MetaBloom se différencie comme **compagnon métabolique inclusif et francophone**, simple, calme, centré sur la prévention et la confidentialité. Il évite les objectifs extrêmes et la gamification punitive.
Les captures mobiles sont disponibles dans [`screenshots/`](./screenshots/).
## Stack
- Expo SDK 57
- React Native 0.86 + React 19
- TypeScript
- AsyncStorage pour les données locales
- Expo Linear Gradient
| Couche | Technologie |
|---|---|
| Application | Expo SDK 57, React Native 0.86, React 19, TypeScript |
| Stockage local | AsyncStorage |
| API | FastAPI, SQLAlchemy, Pydantic |
| Données serveur | PostgreSQL en production, SQLite en développement |
| Sécurité API | JWT expirant, Argon2, isolation par utilisateur |
## Démarrer
## Démarrer lapplication
Prérequis : Node.js 20+ et npm.
@@ -36,41 +41,68 @@ npm install
npm run web
```
Autres cibles :
```bash
npm run android # appareil/émulateur Android ou Expo Go
npm run ios # macOS + simulateur iOS, ou Expo Go
npm run android
npm run ios
npm run typecheck
npm run build:web
```
Les cibles Android/iOS fonctionnent avec Expo Go pour ce prototype. HealthKit/Health Connect nécessiteront un development build natif et des comptes développeur Apple/Google.
## Démarrer lAPI
```bash
cd server
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
uvicorn app.main:app --reload
```
Documentation OpenAPI : `http://localhost:8000/docs`. Voir [`server/README.md`](./server/README.md) pour PostgreSQL, Docker et les règles de sécurité.
## Architecture
```text
App.tsx Interface, navigation et état du MVP
index.ts Point dentrée Expo
app.json Configuration multiplateforme
assets/ Icônes et splash screen
App.tsx Expérience Expo multiplateforme
assets/ Icônes et splash screen
server/app/ API, modèles, validation et sécurité
server/tests/ Tests dintégration API
server/docker-compose... PostgreSQL + API auto-hébergeables
screenshots/ Captures produit
.gitea/workflows/ Contrôles CI
```
Le MVP reste volontairement monolithique pour accélérer la validation produit. Une V2 séparera écrans, composants, domaine et accès aux données.
Lapplication fonctionne actuellement en mode local-first. LAPI est prête séparément ; la prochaine étape dintégration est une file de synchronisation idempotente avec gestion explicite des conflits.
## Données et sécurité
## Sécurité et réglementation
Les données de démonstration et saisies sont stockées exclusivement via AsyncStorage sur lappareil. Aucun backend, tracker publicitaire ou transfert de données nest inclus.
- aucune donnée nest envoyée par lapplication tant que lAPI nest pas configurée ;
- lAPI impose un consentement avant lenregistrement de données de santé ;
- les mots de passe sont hachés avec Argon2 et les données isolées par utilisateur ;
- les secrets dexemple ne doivent jamais être utilisés en production ;
- AsyncStorage nest pas chiffré : il doit être remplacé par SQLite + SecureStore avant production ;
- une AIPD/RGPD, une analyse HDS, un audit OWASP et une qualification MDR sont obligatoires avant usage réel ;
- aucun conseil GLP-1, seuil glycémique ou traitement nest produit par lapplication.
Avant une mise en production réelle : analyse dimpact RGPD, consentement explicite, chiffrement, gestion de leffacement/export, évaluation HDS, audit de sécurité, validation clinique et qualification au regard du règlement européen MDR. Les fonctions de diagnostic, prédiction dhypoglycémie, dosage dinsuline ou adaptation thérapeutique sont explicitement hors périmètre.
## État des fonctionnalités avancées
## Roadmap
- **Backend/auth/export/suppression :** implémentés et isolés dans `server/`.
- **Synchronisation multi-appareil :** architecture définie, connexion client/API restante.
- **Notifications :** préférences fonctionnelles ; branchement natif restant.
- **Scan alimentaire :** expérience préparée ; source nutritionnelle et confirmation utilisateur restantes.
- **HealthKit/Health Connect :** préparation produit ; dépendances natives et autorisations stores restantes.
- **IA :** volontairement non branchée avant gouvernance clinique et corpus validé.
1. Tests utilisateurs et accessibilité WCAG.
2. Authentification et synchronisation chiffrée auto-hébergeable.
3. Export PDF/CSV partageable avec consentement.
4. Rappels locaux et intégrations HealthKit / Health Connect.
5. Base alimentaire francophone et scan, après validation qualité.
6. Parcours professionnels de santé et conformité réglementaire.
## Tests
```bash
npm run typecheck
npm run build:web
cd server && pytest -q
```
## Licence
MIT — voir `LICENSE`.
MIT — voir [`LICENSE`](./LICENSE).
+4 -1
View File
@@ -3,14 +3,17 @@
"name": "MetaBloom",
"slug": "metabloom",
"version": "1.0.0",
"scheme": "metabloom",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"description": "Des habitudes bienveillantes pour un meilleur équilibre métabolique.",
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "agency.vicode.metabloom"
},
"android": {
"package": "agency.vicode.metabloom",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+7
View File
@@ -0,0 +1,7 @@
.env
.venv
__pycache__
.pytest_cache
tests
*.db
+6
View File
@@ -0,0 +1,6 @@
DATABASE_URL=postgresql+psycopg://metabloom:change-me@localhost:5432/metabloom
JWT_SECRET=generate-a-random-secret-of-at-least-32-characters
ENVIRONMENT=production
ALLOWED_ORIGINS=https://app.example.com
POSTGRES_PASSWORD=change-me
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+51
View File
@@ -0,0 +1,51 @@
# MetaBloom API
Backend FastAPI isolé pour synchroniser les profils et journaux MetaBloom. Il fournit JWT, Argon2, séparation stricte des données par utilisateur, consentement explicite, CRUD, export JSON et suppression de compte.
> MetaBloom est un outil de bien-être, pas un dispositif médical. LAPI ne diagnostique pas, ninterprète pas les mesures et ne recommande aucun traitement. En cas de valeur inquiétante ou de symptômes, contacter un professionnel ou les urgences.
## Démarrage local
```bash
cd server
python -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
uvicorn app.main:app --reload
```
Documentation : `http://localhost:8000/docs`. SQLite est utilisé en développement. Pour PostgreSQL, renseigner `DATABASE_URL` daprès `.env.example`.
## Docker
Copier `.env.example` vers `.env`, remplacer **tous** les exemples de secrets, puis :
```bash
docker compose -f docker-compose.example.yml --env-file .env up --build
```
Le port n’écoute que sur localhost ; placer un reverse proxy TLS devant en production.
## API
- `POST /auth/register`, `POST /auth/token`
- `GET/PUT /profile`
- `GET/POST /entries`, `PUT/DELETE /entries/{id}`
- `GET /account/export`, `DELETE /account`
- `GET /health`
Envoyer `Authorization: Bearer <token>`. La création dentrées exige `consent_health_data=true`. Les limites de saisie préviennent les erreurs grossières mais ne constituent pas une validation médicale.
## Tests
```bash
pytest -q
```
## Avant production
- gérer les schémas avec Alembic plutôt que `create_all` ;
- utiliser un gestionnaire de secrets, TLS, sauvegardes chiffrées, rotation et révocation des jetons ;
- ajouter rate limiting, journal daudit sans donnée médicale, MFA et tests de sécurité ;
- effectuer AIPD/RGPD, définir conservation/export, contrats de sous-traitance et hébergement HDS si applicable ;
- faire qualifier juridiquement la finalité et le statut MDR avant toute fonction clinique.
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "sqlite:///./metabloom.db"
jwt_secret: str = "change-me-in-production-at-least-32-characters"
jwt_algorithm: str = "HS256"
access_token_minutes: int = 30
allowed_origins: str = "http://localhost:8081,http://localhost:19006"
environment: str = "development"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@property
def origins(self) -> list[str]:
return [item.strip() for item in self.allowed_origins.split(",") if item.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+109
View File
@@ -0,0 +1,109 @@
from datetime import datetime, timezone
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import get_settings
from .db import Base, engine, get_db
from .models import Entry, Profile, User
from .schemas import EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token
from .security import current_user, hash_password, make_token, verify_password
cfg = get_settings()
if cfg.environment == "production" and (len(cfg.jwt_secret) < 32 or cfg.jwt_secret.startswith("change-me")):
raise RuntimeError("Set a strong JWT_SECRET in production")
app = FastAPI(title="MetaBloom API", version="1.0.0", description="Wellness tracking API — not a medical device.")
app.add_middleware(CORSMiddleware, allow_origins=cfg.origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
@app.on_event("startup")
def startup():
Base.metadata.create_all(engine)
@app.get("/health")
def health():
return {"status": "ok", "medical_notice": "MetaBloom does not diagnose or replace medical care."}
@app.post("/auth/register", response_model=Token, status_code=201)
def register(data: Register, db: Session = Depends(get_db)):
email = data.email.lower()
if db.scalar(select(User).where(User.email == email)):
raise HTTPException(409, "Email already registered")
user = User(email=email, password_hash=hash_password(data.password))
db.add(user); db.flush()
db.add(Profile(user_id=user.id)); db.commit()
return Token(access_token=make_token(user.id))
@app.post("/auth/token", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.scalar(select(User).where(User.email == form.username.lower()))
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(401, "Invalid credentials", headers={"WWW-Authenticate": "Bearer"})
return Token(access_token=make_token(user.id))
@app.get("/profile", response_model=ProfileOut)
def get_profile(user: User = Depends(current_user)):
return user.profile
@app.put("/profile", response_model=ProfileOut)
def put_profile(data: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
profile = user.profile or Profile(user_id=user.id)
for key, value in data.model_dump().items(): setattr(profile, key, value)
db.add(profile); db.commit(); db.refresh(profile)
return profile
@app.get("/entries", response_model=list[EntryOut])
def list_entries(kind: str | None = None, limit: int = 100, user: User = Depends(current_user), db: Session = Depends(get_db)):
limit = min(max(limit, 1), 500)
query = select(Entry).where(Entry.user_id == user.id)
if kind: query = query.where(Entry.kind == kind)
return db.scalars(query.order_by(Entry.recorded_at.desc()).limit(limit)).all()
@app.post("/entries", response_model=EntryOut, status_code=201)
def create_entry(data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
if not user.profile or not user.profile.consent_health_data:
raise HTTPException(403, "Health-data consent is required")
entry = Entry(user_id=user.id, **data.model_dump(exclude_none=True))
db.add(entry); db.commit(); db.refresh(entry)
return entry
def owned_entry(entry_id: str, user: User, db: Session) -> Entry:
entry = db.scalar(select(Entry).where(Entry.id == entry_id, Entry.user_id == user.id))
if not entry: raise HTTPException(404, "Entry not found")
return entry
@app.put("/entries/{entry_id}", response_model=EntryOut)
def update_entry(entry_id: str, data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
entry = owned_entry(entry_id, user, db)
for key, value in data.model_dump(exclude_none=True).items(): setattr(entry, key, value)
db.commit(); db.refresh(entry)
return entry
@app.delete("/entries/{entry_id}", status_code=204)
def delete_entry(entry_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
db.delete(owned_entry(entry_id, user, db)); db.commit()
return Response(status_code=204)
@app.get("/account/export")
def export_account(user: User = Depends(current_user)):
return {"exported_at": datetime.now(timezone.utc), "medical_notice": "Informational data only; consult a professional for medical interpretation.", "profile": ProfileOut.model_validate(user.profile), "entries": [EntryOut.model_validate(x) for x in user.entries]}
@app.delete("/account", status_code=204)
def delete_account(user: User = Depends(current_user), db: Session = Depends(get_db)):
db.delete(user); db.commit()
return Response(status_code=204)
+57
View File
@@ -0,0 +1,57 @@
import enum
import uuid
from datetime import date, datetime, timezone
from sqlalchemy import Boolean, Date, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
def now() -> datetime:
return datetime.now(timezone.utc)
class EntryKind(str, enum.Enum):
weight = "weight"
glucose = "glucose"
water = "water"
activity = "activity"
sleep = "sleep"
mood = "mood"
meal = "meal"
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
profile: Mapped["Profile | None"] = relationship(back_populates="user", cascade="all, delete-orphan")
entries: Mapped[list["Entry"]] = relationship(back_populates="user", cascade="all, delete-orphan")
class Profile(Base):
__tablename__ = "profiles"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
display_name: Mapped[str | None] = mapped_column(String(80))
birth_date: Mapped[date | None] = mapped_column(Date)
height_cm: Mapped[float | None] = mapped_column(Float)
objective: Mapped[str | None] = mapped_column(String(120))
health_context: Mapped[str | None] = mapped_column(Text)
consent_health_data: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
user: Mapped[User] = relationship(back_populates="profile")
class Entry(Base):
__tablename__ = "entries"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
kind: Mapped[EntryKind] = mapped_column(Enum(EntryKind))
value: Mapped[float] = mapped_column(Float)
unit: Mapped[str] = mapped_column(String(24))
note: Mapped[str | None] = mapped_column(String(500))
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
user: Mapped[User] = relationship(back_populates="entries")
+64
View File
@@ -0,0 +1,64 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
from .models import EntryKind
class Register(BaseModel):
email: EmailStr
password: str = Field(min_length=12, max_length=128)
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class ProfileIn(BaseModel):
display_name: str | None = Field(None, max_length=80)
birth_date: date | None = None
height_cm: float | None = Field(None, ge=80, le=250)
objective: str | None = Field(None, max_length=120)
health_context: str | None = Field(None, max_length=1000)
consent_health_data: bool = False
class ProfileOut(ProfileIn):
user_id: str
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
RANGES = {
EntryKind.weight: (20, 500), EntryKind.glucose: (20, 600), EntryKind.water: (0, 15000),
EntryKind.activity: (0, 1440), EntryKind.sleep: (0, 24), EntryKind.mood: (1, 5),
EntryKind.meal: (0, 10000),
}
class EntryIn(BaseModel):
kind: EntryKind
value: float
unit: str = Field(min_length=1, max_length=24)
note: str | None = Field(None, max_length=500)
recorded_at: datetime | None = None
@model_validator(mode="after")
def sensible_range(self):
if self.kind == EntryKind.glucose:
glucose_ranges = {"mg/dL": (20, 600), "mmol/L": (1.1, 33.3)}
if self.unit not in glucose_ranges:
raise ValueError("glucose unit must be mg/dL or mmol/L")
low, high = glucose_ranges[self.unit]
else:
low, high = RANGES[self.kind]
if not low <= self.value <= high:
raise ValueError(f"value must be between {low} and {high} for {self.kind.value}")
return self
class EntryOut(EntryIn):
id: str
user_id: str
recorded_at: datetime
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from .config import get_settings
from .db import get_db
from .models import User
hasher = PasswordHasher()
oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/token")
def hash_password(password: str) -> str:
return hasher.hash(password)
def verify_password(password: str, digest: str) -> bool:
try:
return hasher.verify(digest, password)
except VerifyMismatchError:
return False
def make_token(user_id: str) -> str:
cfg = get_settings()
now = datetime.now(timezone.utc)
return jwt.encode({"sub": user_id, "iat": now, "exp": now + timedelta(minutes=cfg.access_token_minutes)}, cfg.jwt_secret, algorithm=cfg.jwt_algorithm)
def current_user(token: str = Depends(oauth2), db: Session = Depends(get_db)) -> User:
cfg = get_settings()
try:
user_id = jwt.decode(token, cfg.jwt_secret, algorithms=[cfg.jwt_algorithm])["sub"]
except (jwt.PyJWTError, KeyError):
raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"})
user = db.get(User, user_id)
if not user:
raise HTTPException(401, "Account no longer exists")
return user
+27
View File
@@ -0,0 +1,27 @@
services:
api:
build: .
environment:
DATABASE_URL: postgresql+psycopg://metabloom:${POSTGRES_PASSWORD}@db:5432/metabloom
JWT_SECRET: ${JWT_SECRET}
ENVIRONMENT: production
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8081}
depends_on:
db:
condition: service_healthy
ports: ["127.0.0.1:8000:8000"]
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: metabloom
POSTGRES_USER: metabloom
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: ["postgres_data:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U metabloom"]
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres_data:
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.4
httpx==0.28.1
+10
View File
@@ -0,0 +1,10 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg[binary]==3.2.3
pydantic-settings==2.7.0
PyJWT==2.10.1
argon2-cffi==23.1.0
email-validator==2.2.0
python-multipart==0.0.20
+46
View File
@@ -0,0 +1,46 @@
import os
os.environ["DATABASE_URL"] = "sqlite:///./test_metabloom.db"
os.environ["JWT_SECRET"] = "test-secret-that-is-long-enough-for-tests"
from fastapi.testclient import TestClient
from app.db import Base, engine
from app.main import app
def setup_function():
Base.metadata.drop_all(engine); Base.metadata.create_all(engine)
def auth(client):
response = client.post("/auth/register", json={"email": "user@example.com", "password": "a-very-safe-password"})
assert response.status_code == 201
return {"Authorization": f"Bearer {response.json()['access_token']}"}
def test_health_auth_crud_export_delete():
with TestClient(app) as client:
assert client.get("/health").status_code == 200
headers = auth(client)
assert client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"}).status_code == 403
profile = client.put("/profile", headers=headers, json={"display_name": "Alex", "height_cm": 175, "consent_health_data": True})
assert profile.status_code == 200
created = client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"})
assert created.status_code == 201
entry_id = created.json()["id"]
assert len(client.get("/entries", headers=headers).json()) == 1
assert client.put(f"/entries/{entry_id}", headers=headers, json={"kind": "weight", "value": 89, "unit": "kg"}).json()["value"] == 89
assert client.get("/account/export", headers=headers).status_code == 200
assert client.delete(f"/entries/{entry_id}", headers=headers).status_code == 204
assert client.delete("/account", headers=headers).status_code == 204
def test_validation_and_isolation():
with TestClient(app) as client:
headers = auth(client)
client.put("/profile", headers=headers, json={"consent_health_data": True})
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 999, "unit": "mg/dL"}).status_code == 422
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mmol/L"}).status_code == 201
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mg/dL"}).status_code == 422
entries = client.get("/entries", headers=headers).json()
assert len(entries) == 1
assert entries[0]["unit"] == "mmol/L"