Add PHPUnit integration tests, remove legacy pre-Symfony files, fix password reset

- Delete legacy root files (api.php, index.php, app.js, style.css, logo.png, include/)
- Install symfony/test-pack, add 34 integration tests covering auth, goals, invites, register, password reset
- Fix bug: users_resets.selector was varchar(20) but controller generates 24-char selectors; widen to varchar(64)
- Remove doctrine dbname_suffix from test env (tests run against live DB, cleanup via tearDown)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kühn 2026-04-30 10:18:21 +02:00
parent 6503466344
commit 80e418f8b7
19 changed files with 2768 additions and 1232 deletions

3
.env.test Normal file
View file

@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

5
.gitignore vendored
View file

@ -16,3 +16,8 @@
/.aider*
/docs/private/
###< tools ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

294
api.php
View file

@ -1,294 +0,0 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', '/tmp/zt.log');
set_exception_handler(function(\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
exit;
});
require_once __DIR__ . '/include/db.php';
require_once __DIR__ . '/include/mailer.php';
header('Content-Type: application/json; charset=utf-8');
// CORS für gleiche Domain nicht nötig; bei separaten Dev-Domains ggf. ergänzen
$method = $_SERVER['REQUEST_METHOD'];
$segments = explode('/', trim($_GET['_path'] ?? '', '/'));
$resource = $segments[0] ?? '';
$resourceId = isset($segments[1]) && $segments[1] !== '' ? (int)$segments[1] : null;
$body = json_decode(file_get_contents('php://input'), true) ?? [];
function json_out(array $data, int $status = 200): never {
http_response_code($status);
echo json_encode($data);
exit;
}
function require_auth(\Delight\Auth\Auth $auth): int {
if (!$auth->isLoggedIn()) {
json_out(['error' => 'Unauthorized'], 401);
}
return $auth->getUserId();
}
// ── POST /api/login ──────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'login') {
$email = trim($body['email'] ?? '');
$pass = $body['password'] ?? '';
try {
$auth->login($email, $pass, 86400);
json_out(['ok' => true, 'email' => $auth->getEmail()]);
} catch (\Delight\Auth\InvalidEmailException
| \Delight\Auth\InvalidPasswordException
| \Delight\Auth\UserNotFound $e) {
json_out(['error' => 'Falsche E-Mail oder Passwort'], 401);
} catch (\Delight\Auth\TooManyRequestsException $e) {
json_out(['error' => 'Zu viele Versuche, bitte warten'], 429);
}
}
// ── POST /api/logout ─────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'logout') {
$auth->logOut();
json_out(['ok' => true]);
}
// ── GET /api/me ───────────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'me') {
if ($auth->isLoggedIn()) {
$name = $conn->fetchOne('SELECT username FROM users WHERE id = ?', [$auth->getUserId()]);
json_out(['ok' => true, 'email' => $auth->getEmail(), 'id' => $auth->getUserId(), 'name' => $name ?: '']);
}
json_out(['ok' => false], 401);
}
// ── PATCH /api/me ─────────────────────────────────────────────────────────────
if ($method === 'PATCH' && $resource === 'me') {
$uid = require_auth($auth);
$name = trim($body['name'] ?? '');
if (!$name) json_out(['error' => 'Name fehlt'], 400);
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $uid]);
json_out(['ok' => true, 'name' => $name]);
}
// ── POST /api/register ────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'register') {
$email = trim($body['email'] ?? '');
$pass = $body['password'] ?? '';
$token = trim($body['token'] ?? '');
if (!$email || !$pass || !$token) {
json_out(['error' => 'Fehlende Felder'], 400);
}
// Token validieren
$invite = $conn->fetchAssociative(
'SELECT * FROM invites WHERE token = ? AND used_by IS NULL AND expires_at > NOW()',
[$token]
);
if (!$invite) {
json_out(['error' => 'Ungültiger oder abgelaufener Einladungslink'], 400);
}
$name = trim($body['name'] ?? '');
try {
$userId = $auth->register($email, $pass);
} catch (\Delight\Auth\InvalidEmailException $e) {
json_out(['error' => 'Ungültige E-Mail'], 400);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
} catch (\Delight\Auth\UserAlreadyExistsException $e) {
json_out(['error' => 'E-Mail bereits registriert'], 409);
}
// Token als verwendet markieren
$conn->executeStatement(
'UPDATE invites SET used_by = ?, used_at = NOW() WHERE token = ?',
[$userId, $token]
);
if ($name) {
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $userId]);
}
// Direkt einloggen
$auth->login($email, $pass, 86400);
json_out(['ok' => true, 'email' => $auth->getEmail(), 'name' => $name]);
}
// ── POST /api/reset-request ──────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'reset-request') {
$email = trim($body['email'] ?? '');
if (!$email) json_out(['error' => 'E-Mail fehlt'], 400);
try {
$auth->requestPasswordReset($email, function (string $selector, string $token) use ($email) {
$url = APP_URL . '/?reset_selector=' . rawurlencode($selector) . '&reset_token=' . rawurlencode($token);
$text = "Hallo,\n\nklicke auf den folgenden Link um dein Passwort zurückzusetzen (gültig 24h):\n\n$url\n\nFalls du das nicht angefragt hast, ignoriere diese E-Mail.";
sendMail($email, 'Durchzieh-Dienst Passwort zurücksetzen', $text);
});
} catch (\Delight\Auth\InvalidEmailException $e) {
// Keinen Hinweis geben ob die E-Mail existiert
} catch (\Exception $e) {
json_out(['error' => 'Mail konnte nicht gesendet werden'], 500);
}
json_out(['ok' => true]); // immer ok, auch wenn E-Mail unbekannt
}
// ── POST /api/reset-password ──────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'reset-password') {
$selector = $body['selector'] ?? '';
$token = $body['token'] ?? '';
$pass = $body['password'] ?? '';
if (!$selector || !$token || !$pass) json_out(['error' => 'Fehlende Felder'], 400);
try {
$auth->resetPassword($selector, $token, $pass);
json_out(['ok' => true]);
} catch (\Delight\Auth\InvalidSelectorTokenPairException $e) {
json_out(['error' => 'Ungültiger oder abgelaufener Reset-Link'], 400);
} catch (\Delight\Auth\TokenExpiredException $e) {
json_out(['error' => 'Reset-Link abgelaufen, bitte neu anfordern'], 400);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
}
}
// ── POST /api/change-password ─────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'change-password') {
$uid = require_auth($auth);
$oldPass = $body['old_password'] ?? '';
$newPass = $body['new_password'] ?? '';
if (!$oldPass || !$newPass) json_out(['error' => 'Fehlende Felder'], 400);
try {
$auth->changePassword($oldPass, $newPass);
json_out(['ok' => true]);
} catch (\Delight\Auth\NotLoggedInException $e) {
json_out(['error' => 'Nicht eingeloggt'], 401);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Neues Passwort zu kurz (min. 8 Zeichen)'], 400);
} catch (\Delight\Auth\TooManyRequestsException $e) {
json_out(['error' => 'Zu viele Versuche'], 429);
}
}
// ── POST /api/invite ─────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'invite') {
$uid = require_auth($auth);
$token = bin2hex(random_bytes(32));
$note = trim($body['note'] ?? '');
$conn->executeStatement(
'INSERT INTO invites (token, note, created_by, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))',
[$token, $note ?: null, $uid]
);
json_out(['url' => APP_URL . '/?invite=' . $token]);
}
// ── GET /api/invites ──────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'invites') {
$uid = require_auth($auth);
$rows = $conn->fetchAllAssociative(
'SELECT i.token, i.created_at, i.expires_at, i.used_at, i.note, u.email AS used_by_email
FROM invites i
LEFT JOIN users u ON u.id = i.used_by
WHERE i.created_by = ?
ORDER BY i.created_at DESC',
[$uid]
);
$result = array_map(function ($r) {
if ($r['used_by_email'] !== null) {
$status = 'used';
} elseif (strtotime($r['expires_at']) < time()) {
$status = 'expired';
} else {
$status = 'pending';
}
return [
'url' => $status === 'pending' ? APP_URL . '/?invite=' . $r['token'] : null,
'created_at' => $r['created_at'],
'expires_at' => $r['expires_at'],
'used_at' => $r['used_at'],
'note' => $r['note'],
'used_by_email' => $r['used_by_email'],
'status' => $status,
];
}, $rows);
json_out($result);
}
// ── GET /api/goals ────────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'goals') {
$uid = require_auth($auth);
$rows = $conn->fetchAllAssociative(
'SELECT id, name, unit, daily, days, start, sets FROM goals WHERE user_id = ? ORDER BY created_at ASC',
[$uid]
);
$goals = array_map(function ($r) {
$r['sets'] = json_decode($r['sets'], true) ?: [];
$r['id'] = (string)$r['id'];
return $r;
}, $rows);
json_out($goals);
}
// ── POST /api/goals ───────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'goals') {
$uid = require_auth($auth);
$name = trim($body['name'] ?? '');
$unit = trim($body['unit'] ?? 'Stück');
$daily = (float)($body['daily'] ?? 1);
$days = (int) ($body['days'] ?? 30);
try { $start = (new DateTime($body['start'] ?? 'now'))->format('Y-m-d H:i:s'); }
catch (\Exception $e) { $start = date('Y-m-d H:i:s'); }
$setsJson = isset($body['sets']) && is_array($body['sets']) ? json_encode((object)$body['sets']) : '{}';
if (!$name) json_out(['error' => 'Name fehlt'], 400);
$conn->executeStatement(
'INSERT INTO goals (user_id, name, unit, daily, days, start, sets) VALUES (?, ?, ?, ?, ?, ?, ?)',
[$uid, $name, $unit, $daily, $days, $start, $setsJson]
);
$id = (string)$conn->lastInsertId();
json_out(['id' => $id, 'name' => $name, 'unit' => $unit, 'daily' => $daily, 'days' => $days, 'start' => $start, 'sets' => json_decode($setsJson, true)]);
}
// ── PATCH /api/goals/{id} ─────────────────────────────────────────────────────
if ($method === 'PATCH' && $resource === 'goals' && $resourceId !== null) {
$uid = require_auth($auth);
// Sicherstellen dass das Ziel dem User gehört
$existing = $conn->fetchOne('SELECT id FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
if (!$existing) json_out(['error' => 'Nicht gefunden'], 404);
$fields = [];
$params = [];
foreach (['name', 'unit'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (string)$body[$f]; }
}
foreach (['daily'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (float)$body[$f]; }
}
foreach (['days'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (int)$body[$f]; }
}
if (isset($body['sets'])) {
$fields[] = 'sets = ?';
$params[] = json_encode($body['sets']);
}
if (!$fields) json_out(['ok' => true]);
$params[] = $resourceId;
$params[] = $uid;
$conn->executeStatement('UPDATE goals SET ' . implode(', ', $fields) . ' WHERE id = ? AND user_id = ?', $params);
json_out(['ok' => true]);
}
// ── DELETE /api/goals/{id} ────────────────────────────────────────────────────
if ($method === 'DELETE' && $resource === 'goals' && $resourceId !== null) {
$uid = require_auth($auth);
$conn->executeStatement('DELETE FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
json_out(['ok' => true]);
}
json_out(['error' => 'Not found'], 404);

716
app.js
View file

@ -1,716 +0,0 @@
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {};
var userName = '';
function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
prefs = loadPref('zt_p','{}');
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
function heuteColor(tdone,daily){
if(tdone===0) return 'var(--red)';
if(tdone>=daily*1.1) return 'var(--blue)';
if(tdone>=daily) return 'var(--green)';
return 'var(--amber)';
}
function isCollapsed(id){ return collapsed[id]!==false; }
function toggleCollapse(id){
var wasCollapsed=isCollapsed(id);
collapsed[id]=!wasCollapsed;
if(wasCollapsed){
var g=goals.filter(function(x){return x.id===id;})[0];
if(g) selDay[id]=tOff(g);
}
render();
}
function calc(g){
var t=tOff(g), tot=g.daily*g.days;
var dr=Math.max(0,g.days-t-1);
var sd=new Date(g.start); sd.setHours(0,0,0,0);
var end=new Date(sd.getTime()+g.days*86400000);
var past=0;
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
var tdone=dTot(g,t), tot2=past+tdone;
var dl=dr+1;
var remaining=Math.max(0,tot-past);
var pd=Math.ceil(remaining/Math.max(1,dl));
var st=Math.max(0,pd-tdone);
var expectedPast=Math.min(t,g.days)*g.daily;
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
var deficit=Math.min(0,buf);
var surplus=Math.max(0,buf);
var dailyDelta=pd-g.daily;
var pct=Math.min(100,Math.round((tot2/tot)*100));
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
}
function dcls(g,i){
var t=tOff(g); if(i>t) return 'dot df';
var v=dTot(g,i);
var c=v===0?'dot dm':v>=g.daily*1.1?'dot db':v>=g.daily?'dot dd':'dot dp';
return c+(editable(g,i)?' de':' dl');
}
function dlbl(g,i){
var t=tOff(g); if(i>t) return String(i+1);
var v=dTot(g,i);
if(v===0) return '✕'; if(v>=g.daily*1.1) return '+'; if(v>=g.daily) return '✓';
return Math.round(v/g.daily*100)+'%';
}
// ── API ──────────────────────────────────────────────────────────────────────
function api(method, path, body){
var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}};
if(body) opts.body = JSON.stringify(body);
return fetch('api/' + path, opts).then(function(res){
return res.json().then(function(data){
if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; }
return data;
});
});
}
function loadGoals(){
return api('GET','goals').then(function(data){ return data; });
}
function saveGoal(g){
api('PATCH','goals/'+g.id,{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets})
.catch(function(e){
if(e.status===401){ showLogin(); }
else showToast('Speicherfehler');
});
}
// ── Toast ─────────────────────────────────────────────────────────────────────
function showToast(msg){
var t=document.createElement('div'); t.className='toast'; t.textContent=msg;
document.body.appendChild(t); setTimeout(function(){t.remove();},3000);
}
// ── Goal-Aktionen ─────────────────────────────────────────────────────────────
function addSet(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
var k=gid+'_'+off, amt=parseInt(addAmt[k]||'0',10);
if(amt<=0) return;
if(!g.sets[String(off)]) g.sets[String(off)]=[];
g.sets[String(off)].push({amount:amt,time:off===tOff(g)?now():'—'});
addAmt[k]=''; saveGoal(g); render();
}
function remSet(gid,off,idx){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
g.sets[String(off)].splice(idx,1); saveGoal(g); render();
}
function delGoal(id){
if(!confirm('Ziel wirklich löschen?')) return;
goals=goals.filter(function(g){return g.id!==id;});
render();
api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); });
}
function selD(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
selDay[gid]=selDay[gid]===off?null:off; render();
}
function startRen(id){
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
renamingId=id; renameVal=g.name; render();
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
}
function commitRen(id){
var g=goals.filter(function(x){return x.id===id;})[0];
if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);}
renamingId=null; render();
}
function cancelRen(){ renamingId=null; render(); }
// ── Template-Helper ───────────────────────────────────────────────────────────
function tpl(id){
return document.getElementById(id).content.cloneNode(true).firstElementChild;
}
// ── Overlays ──────────────────────────────────────────────────────────────────
var OV_CSS='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;justify-content:center;animation:fi .2s ease';
function closeOv(){
var o=document.getElementById('ov');
o.style.display='none';
o.innerHTML='';
}
function showSheet(content, dismissable){
var o=document.getElementById('ov');
o.style.cssText=OV_CSS;
var sheet=tpl('tpl-sheet');
sheet.appendChild(content);
o.innerHTML='';
o.appendChild(sheet);
o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null;
}
// ── Login ─────────────────────────────────────────────────────────────────────
function showLogin(err){
var c=tpl('tpl-login');
if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; }
showSheet(c,false);
var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub');
setTimeout(function(){email.focus();},50);
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
pass.onkeydown=function(e){if(e.key==='Enter')sub.click();};
c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();};
sub.onclick=function(){
var ev=email.value.trim(), pv=pass.value;
if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','login',{email:ev,password:pv})
.then(function(){ return loadGoals(); })
.then(function(g){ goals=g; closeOv(); render(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Anmelden';
showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler');
});
};
}
// ── Passwort vergessen ────────────────────────────────────────────────────────
function showForgotPassword(){
var c=tpl('tpl-forgot-pw');
showSheet(c,false);
var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub');
setTimeout(function(){email.focus();},50);
c.querySelector('.fp-back').onclick=function(){showLogin();};
sub.onclick=function(){
var ev=email.value.trim(); if(!ev) return;
sub.disabled=true; sub.textContent='…';
api('POST','reset-request',{email:ev})
.then(function(){
var conf=tpl('tpl-email-sent');
conf.querySelector('.es-ok').onclick=function(){showLogin();};
showSheet(conf,false);
})
.catch(function(err){
sub.disabled=false; sub.textContent='Link senden';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Passwort zurücksetzen ─────────────────────────────────────────────────────
function showResetPassword(selector,token){
var c=tpl('tpl-reset-pw');
showSheet(c,false);
var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub');
setTimeout(function(){pass.focus();},50);
sub.onclick=function(){
var pv=pass.value; if(!pv) return;
sub.disabled=true; sub.textContent='…';
api('POST','reset-password',{selector:selector,token:token,password:pv})
.then(function(){
var conf=tpl('tpl-pw-changed');
conf.querySelector('.pc-ok').onclick=function(){showLogin();};
showSheet(conf,false);
})
.catch(function(err){
sub.disabled=false; sub.textContent='Passwort setzen';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Passwort ändern ───────────────────────────────────────────────────────────
function showChangePassword(){
var c=tpl('tpl-change-pw');
showSheet(c,true);
var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2');
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub');
setTimeout(function(){oldP.focus();},50);
c.querySelector('.cp-can').onclick=closeOv;
sub.onclick=function(){
var o=oldP.value, n=newP.value, n2=newP2.value;
if(!o||!n||!n2) return;
if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','change-password',{old_password:o,new_password:n})
.then(function(){ showToast('Passwort geändert'); closeOv(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Ändern';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Registrierung ─────────────────────────────────────────────────────────────
function showRegister(token){
var c=tpl('tpl-register');
showSheet(c,false);
var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email');
var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2');
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub');
setTimeout(function(){nameInp.focus();},50);
nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();};
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();};
pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();};
function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } }
pass.oninput=checkMatch; pass2.oninput=checkMatch;
sub.onclick=function(){
var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value;
if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; }
if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','register',{name:nv,email:ev,password:pv,token:token})
.then(function(r){ userName=r.name||''; return loadGoals(); })
.then(function(g){ goals=g; closeOv(); updateHeader(); render(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Registrieren';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Neues Ziel ────────────────────────────────────────────────────────────────
function openNew(){
var c=tpl('tpl-new-goal');
showSheet(c,true);
var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit');
var daily=c.querySelector('.ng-daily'), days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub');
setTimeout(function(){name.focus();},50);
c.querySelector('.ng-can').onclick=closeOv;
sub.onclick=function(){
var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück';
var dv=parseInt(daily.value,10)||1, dyv=parseInt(days.value,10)||30;
if(!nv){ name.focus(); return; }
sub.disabled=true;
api('POST','goals',{name:nv,unit:uv,daily:dv,days:dyv,start:TODAY.toISOString()})
.then(function(r){
goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}});
closeOv(); render();
}).catch(function(e){
sub.disabled=false;
if(e.status===401){ closeOv(); showLogin(); }
else showToast('Fehler beim Erstellen');
});
};
}
// ── Daten-Menü ────────────────────────────────────────────────────────────────
function openData(){
var c=tpl('tpl-data-menu');
showSheet(c,true);
c.querySelector('.dm-cls').onclick=closeOv;
c.querySelector('.dm-name').onclick=function(){
var nc=tpl('tpl-change-name');
showSheet(nc,true);
var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub');
inp.value=userName;
setTimeout(function(){inp.focus();inp.select();},50);
nc.querySelector('.cn-can').onclick=closeOv;
sub.onclick=function(){
var nv=inp.value.trim();
if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('PATCH','me',{name:nv})
.then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); })
.catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); });
};
};
c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); };
c.querySelector('.dm-lgout').onclick=function(){
api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); });
};
c.querySelector('.dm-inv').onclick=function(){
var ic=tpl('tpl-invite-form');
showSheet(ic,true);
var invName=ic.querySelector('.inv-name');
setTimeout(function(){invName.focus();},50);
ic.querySelector('.inv-cancel').onclick=closeOv;
ic.querySelector('.inv-gen').onclick=function(){
var note=invName.value.trim(), btn=this;
btn.disabled=true; btn.textContent='…';
api('POST','invite',{note:note}).then(function(res){
var lc=tpl('tpl-invite-link');
lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:'');
var urlInp=lc.querySelector('.il-url');
urlInp.value=res.url;
showSheet(lc,true);
lc.querySelector('.il-close').onclick=closeOv;
lc.querySelector('.il-copy').onclick=function(){
navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); });
};
setTimeout(function(){urlInp.select();},50);
}).catch(function(){
btn.disabled=false; btn.textContent='Link generieren';
showToast('Fehler beim Generieren');
});
};
};
c.querySelector('.dm-invlist').onclick=function(){
api('GET','invites').then(function(list){
var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'};
var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'};
var lc=tpl('tpl-invite-list');
var body=lc.querySelector('.dpanel-body');
if(!list.length){
var empty=document.createElement('div');
empty.className='nosets'; empty.style.padding='16px';
empty.textContent='Noch keine Einladungen verschickt';
body.appendChild(empty);
} else {
for(var i=0;i<list.length;i++){
var inv=list[i];
var label=inv.note||new Date(inv.created_at).toLocaleDateString('de-DE',{day:'numeric',month:'short',year:'numeric'});
var detail=inv.used_by_email?('→ '+inv.used_by_email):(inv.status==='pending'?'läuft ab: '+new Date(inv.expires_at).toLocaleDateString('de-DE',{day:'numeric',month:'short'}):'');
var row=tpl('tpl-invite-row');
row.querySelector('.ir-label').textContent=label;
if(detail) row.querySelector('.ir-detail').textContent=' '+detail;
var st=row.querySelector('.ir-status');
st.textContent=statusLabel[inv.status]; st.style.color=statusColor[inv.status];
if(inv.url){
var cp=row.querySelector('.ir-copy'); cp.style.display='';
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast('Link kopiert!'); }); }; }(inv.url);
}
body.appendChild(row);
}
}
showSheet(lc,true);
lc.querySelector('.il-close').onclick=closeOv;
}).catch(function(){ showToast('Fehler beim Laden'); });
};
c.querySelector('.dm-exp').onclick=function(){
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
var url=URL.createObjectURL(blob), a=document.createElement('a');
a.href=url; a.download='dudi-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
};
c.querySelector('.dm-imp').onclick=function(){
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
inp.onchange=function(e){
var f=e.target.files[0]; if(!f) return;
var r=new FileReader(); r.onload=function(ev){
try{
var p=JSON.parse(ev.target.result);
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
if(!confirm(p.goals.length+' Ziel(e) importieren?')) return;
var promises=p.goals.map(function(g){
return api('POST','goals',{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets||{}})
.then(function(r){ goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}}); });
});
Promise.all(promises).then(function(){ closeOv(); render(); alert(p.goals.length+' Ziel(e) importiert.'); });
}catch(err){ alert('Fehler: '+err.message); }
}; r.readAsText(f);
}; inp.click();
};
c.querySelector('.dm-clr').onclick=function(){
if(!confirm('Alle Daten löschen?')) return;
var ids=goals.map(function(g){return g.id;}); goals=[]; render();
Promise.all(ids.map(function(id){return api('DELETE','goals/'+id);}))
.catch(function(){ showToast('Fehler beim Löschen'); });
closeOv();
};
}
// ── Card-Bausteine ────────────────────────────────────────────────────────────
function buildNameWrap(g){
if(renamingId===g.id){
var el=tpl('tpl-name-edit');
var inp=el.querySelector('.ren-input');
inp.id='ri'+g.id; inp.value=g.name; inp.dataset.g=g.id;
return el;
}
var el=tpl('tpl-name-view');
el.querySelector('.goal-name').textContent=g.name;
el.querySelector('.btn-ren').dataset.g=g.id;
return el;
}
function buildPanel(g,off){
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
var el=tpl('tpl-panel');
el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off));
el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit;
var body=el.querySelector('.dpanel-body');
if(sets.length){
for(var i=0;i<sets.length;i++){
var s=sets[i], row=tpl('tpl-set-row'), span=row.querySelector('span');
if(s.time!=='—'){
var st=document.createElement('span'); st.className='stime'; st.textContent=s.time+' ·';
span.appendChild(st); span.appendChild(document.createTextNode(' '));
}
var strong=document.createElement('strong'); strong.textContent=s.amount;
span.appendChild(strong); span.appendChild(document.createTextNode(' '+g.unit));
var btn=row.querySelector('.sdel');
btn.dataset.g=g.id; btn.dataset.o=off; btn.dataset.i=i;
body.appendChild(row);
}
} else {
body.appendChild(tpl('tpl-nosets'));
}
var addRow=tpl('tpl-add-row');
var inp=addRow.querySelector('.num-in');
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=off;
var abtn=addRow.querySelector('.btn-as');
abtn.dataset.g=g.id; abtn.dataset.o=off;
addRow.querySelector('.ulbl').textContent=g.unit;
body.appendChild(addRow);
return el;
}
function buildCard(g){
var c=calc(g), t=c.tOff;
var fc=c.surplus>0?'var(--blue)':c.st===0?'var(--green)':c.dailyDelta<=0?'var(--green)':c.dailyDelta<=g.daily*.2?'var(--amber)':'var(--red)';
var bc,bt,bufStr=(c.buf>0?'+':'')+c.buf;
if(c.ok&&c.surplus>0){bc='b-buf';bt=bufStr;}
else if(c.ok){bc='b-done';bt=bufStr;}
else if(c.dailyDelta<=0){bc='b-ok';bt=bufStr;}
else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt=bufStr;}
else{bc='b-danger';bt=bufStr;}
var el;
if(isCollapsed(g.id)){
el=tpl('tpl-card-collapsed');
if(c.ok) el.classList.add('done');
el.querySelector('.card-hdr').dataset.g=g.id;
var bd=el.querySelector('.card-bd');
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
var hc=heuteColor(c.tdone,g.daily);
el.querySelector('.m-dr').textContent=c.dr;
el.querySelector('.m-end').textContent=fs(c.end);
var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc;
el.querySelector('.m-total').textContent=c.done+'/'+c.tot;
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
return el;
}
el=tpl('tpl-card-expanded');
if(c.ok) el.classList.add('done');
el.querySelector('.card-hdr').dataset.g=g.id;
var bd=el.querySelector('.card-bd');
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
el.querySelector('.m-dr').textContent=c.dr;
el.querySelector('.m-end').textContent=fs(c.end);
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht';
el.querySelector('.pr-pct').textContent=c.pct+'% von '+c.tot;
el.querySelector('.sv-tdone').textContent=c.tdone;
el.querySelector('.sv-daily').textContent=g.daily;
el.querySelector('.sv-st').textContent=c.st;
el.querySelector('.sv-noch').style.color=heuteColor(c.tdone,g.daily);
el.querySelectorAll('.sunit').forEach(function(u){ u.textContent=g.unit; });
var sel=selDay[g.id]!=null?selDay[g.id]:t;
var dotsWrap=el.querySelector('.dots-wrap');
for(var i=0;i<g.days;i++){
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
var dot=tpl('tpl-dot');
dot.className=dcls(g,i)+(is?' rs':it?' rt':iy&&t>0?' ry':'');
if(ed){ dot.dataset.g=g.id; dot.dataset.d=i; }
dot.textContent=dlbl(g,i);
dotsWrap.appendChild(dot);
}
if(sel!=null) el.insertBefore(buildPanel(g,sel),el.querySelector('.card-foot'));
el.querySelector('.btn-del').dataset.g=g.id;
return el;
}
// ── Quick-Buchen ──────────────────────────────────────────────────────────────
function buildQuickBook(){
var active=goals.filter(function(g){ var c=calc(g); return tOff(g)<g.days&&!c.ok; });
if(!active.length) return null;
var frag=document.createDocumentFragment();
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent='Quick-Buchen';
frag.appendChild(lbl);
var card=document.createElement('div'); card.className='card qb-card';
for(var i=0;i<active.length;i++){
var g=active[i], c=calc(g), k=g.id+'_'+c.tOff;
var row=tpl('tpl-qb-row');
row.querySelector('.qb-name').textContent=g.name;
var stat=row.querySelector('.qb-stat'); stat.textContent=c.tdone+'/'+g.daily; stat.style.color=heuteColor(c.tdone,g.daily);
var inp=row.querySelector('.num-in');
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=c.tOff;
var btn=row.querySelector('.btn-as'); btn.dataset.g=g.id; btn.dataset.o=c.tOff;
card.appendChild(row);
}
frag.appendChild(card);
return frag;
}
// ── Render ────────────────────────────────────────────────────────────────────
function calcAwards(){
var units=0;
for(var i=0;i<goals.length;i++){
var g=goals[i];
if(tOff(g)>=g.days) units+=Math.floor(g.days/30);
}
var gold=Math.floor(units/25); units%=25;
var silver=Math.floor(units/5); var bronze=units%5;
return{gold:gold,silver:silver,bronze:bronze};
}
function render(){
var m=document.getElementById('main');
var frag=document.createDocumentFragment();
if(!prefs.hd){
var hint=tpl('tpl-hint');
hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); };
frag.appendChild(hint);
}
var aw=calcAwards();
if(aw.gold||aw.silver||aw.bronze){
var awards=document.createElement('div'); awards.className='awards';
var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]];
for(var mi=0;mi<medals.length;mi++){
for(var ai=0;ai<medals[mi][1];ai++){
var sp=document.createElement('span'); sp.className='aw'; sp.textContent=medals[mi][0];
awards.appendChild(sp);
}
}
frag.appendChild(awards);
}
if(!goals.length){
frag.appendChild(tpl('tpl-empty'));
m.innerHTML=''; m.appendChild(frag); wire(); return;
}
if(userName){
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent='Hallo '+userName+'!';
frag.appendChild(gr);
}
var qb=buildQuickBook(); if(qb) frag.appendChild(qb);
var open=[],done=[];
for(var gi=0;gi<goals.length;gi++){
var g=goals[gi], c=calc(g);
if(c.ok) done.push(g); else open.push(g);
}
if(open.length){
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent='Offen';
frag.appendChild(sl);
for(var i=0;i<open.length;i++) frag.appendChild(buildCard(open[i]));
}
if(done.length){
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent='Heute erledigt';
frag.appendChild(sl2);
for(var j=0;j<done.length;j++) frag.appendChild(buildCard(done[j]));
}
m.innerHTML=''; m.appendChild(frag); wire();
}
function wire(){
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
el.onclick=function(e){
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
toggleCollapse(this.dataset.g);
};
});
document.querySelectorAll('.btn-ren').forEach(function(b){
b.onclick=function(e){e.stopPropagation();startRen(this.dataset.g);};
});
document.querySelectorAll('.ren-input').forEach(function(inp){
var gid=inp.dataset.g;
inp.oninput=function(){renameVal=this.value;};
inp.onkeydown=function(e){if(e.key==='Enter')commitRen(gid);if(e.key==='Escape')cancelRen();};
inp.onblur=function(){commitRen(gid);};
});
document.querySelectorAll('.de').forEach(function(d){
d.onclick=function(e){e.stopPropagation();selD(this.dataset.g,parseInt(this.dataset.d,10));};
});
document.querySelectorAll('.btn-as').forEach(function(b){
b.onclick=function(){addSet(this.dataset.g,parseInt(this.dataset.o,10));};
});
document.querySelectorAll('.num-in').forEach(function(inp){
var k=inp.dataset.k, g=inp.dataset.g, o=parseInt(inp.dataset.o,10);
inp.oninput=function(){addAmt[k]=this.value;};
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
});
document.querySelectorAll('.sdel').forEach(function(b){
b.onclick=function(){remSet(this.dataset.g,parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
});
document.querySelectorAll('.btn-del').forEach(function(b){
b.onclick=function(){delGoal(this.dataset.g);};
});
}
// ── Start ─────────────────────────────────────────────────────────────────────
function updateHeader(){
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
}
document.getElementById('btnNew').onclick=openNew;
document.getElementById('btnData').onclick=openData;
updateHeader();
var _qs=new URLSearchParams(window.location.search);
var inviteToken=_qs.get('invite');
var resetSelector=_qs.get('reset_selector');
var resetToken=_qs.get('reset_token');
if(inviteToken||resetSelector) history.replaceState(null,'',location.pathname);
if(resetSelector&&resetToken){
render(); showResetPassword(resetSelector,resetToken);
} else {
api('GET','me')
.then(function(r){ userName=r.name||''; updateHeader(); return loadGoals(); })
.then(function(g){ goals=g; render(); })
.catch(function(){
render();
if(inviteToken){ showRegister(inviteToken); }
else { showLogin(); }
});
}
function scheduleMidnight(){
var n=new Date();
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
setTimeout(function(){
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
updateHeader();render();scheduleMidnight();
},ms);
}
scheduleMidnight();
document.addEventListener('visibilitychange',function(){
if(document.visibilityState==='visible'){
var n=new Date();n.setHours(0,0,0,0);
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
loadGoals().then(function(g){goals=g;render();}).catch(function(){});
}
});

4
bin/phpunit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

View file

@ -62,5 +62,10 @@
"allow-contrib": false,
"require": "8.0.*"
}
},
"require-dev": {
"phpunit/phpunit": "^13.1",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
}
}

2073
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -19,11 +19,6 @@ doctrine:
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:

View file

@ -1240,16 +1240,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* security?: SecurityConfig,
* twig?: TwigConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
* security?: SecurityConfig,
* twig?: TwigConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@ -1352,7 +1342,6 @@ namespace Symfony\Component\Routing\Loader\Configurator;
* deprecated?: array{package:string, version:string, message?:string},
* }
* @psalm-type RoutesConfig = array{
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* ...<string, RouteConfig|ImportConfig|AliasConfig>

View file

@ -1,60 +0,0 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/config.php';
$conn = \Doctrine\DBAL\DriverManager::getConnection([
'driver' => 'pdo_mysql',
'host' => DB_HOST,
'dbname' => DB_NAME,
'user' => DB_USER,
'password' => DB_PASS,
'charset' => 'utf8mb4',
]);
// PDO für delight-im/auth
$pdo = $conn->getNativeConnection();
$auth = new \Delight\Auth\Auth($pdo);
// Tabellen anlegen falls nicht vorhanden
$conn->executeStatement('
CREATE TABLE IF NOT EXISTS goals (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
unit VARCHAR(50) NOT NULL DEFAULT \'Stück\',
daily FLOAT NOT NULL DEFAULT 1,
days INT NOT NULL DEFAULT 30,
start DATETIME NOT NULL,
sets JSON NOT NULL DEFAULT (\'[]\'),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
');
$conn->executeStatement('
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(64) NOT NULL PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
');
$conn->executeStatement('
CREATE TABLE IF NOT EXISTS invites (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE,
note VARCHAR(255) DEFAULT NULL,
created_by INT NOT NULL,
used_by INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP DEFAULT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
');
// Migrations ausführen
foreach (glob(__DIR__ . '/../migrations/*.sql') as $file) {
$version = basename($file, '.sql');
if (!$conn->fetchOne('SELECT 1 FROM schema_migrations WHERE version = ?', [$version])) {
$conn->executeStatement(file_get_contents($file));
$conn->executeStatement('INSERT INTO schema_migrations (version) VALUES (?)', [$version]);
}
}

View file

@ -1,19 +0,0 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
function sendMail(string $to, string $subject, string $body): void {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = SMTP_HOST;
$mail->SMTPAuth = true;
$mail->Username = SMTP_USER;
$mail->Password = SMTP_PASS;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = SMTP_PORT;
$mail->CharSet = 'UTF-8';
$mail->setFrom(SMTP_FROM, SMTP_FROM_NAME);
$mail->addAddress($to);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->send();
}

View file

@ -1,8 +0,0 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
$twig = new \Twig\Environment(
new \Twig\Loader\FilesystemLoader(__DIR__ . '/templates'),
['cache' => false]
);
echo $twig->render('app.html.twig');

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260430000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Fix users_resets.selector column size (bin2hex(random_bytes(12)) produces 24 chars, was varchar(20))';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users_resets CHANGE selector selector VARCHAR(64) NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users_resets CHANGE selector selector VARCHAR(20) NOT NULL');
}
}

44
phpunit.dist.xml Normal file
View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

113
style.css
View file

@ -1,113 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
.main-wrap{max-width:480px;margin:0 auto}
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
.hdr-logo{height:100px;width:auto;mix-blend-mode:multiply;display:block}
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
.hdr-btns{display:flex;gap:8px;align-items:center}
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
.btn-add:active{transform:scale(.92)}
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
.btn-menu:active{transform:scale(.92)}
.main{padding:16px 16px 0}
.greeting{font-size:17px;font-weight:600;letter-spacing:-.2px;padding:0 2px 10px}
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.card.done{opacity:.7}
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
.name-wrap{display:flex;align-items:center;gap:6px}
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
.btn-ren:active{color:var(--blue)}
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
.ren-input:focus{outline:none}
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
.prog-wrap{padding:0 16px 12px}
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
.heute-stats{padding:0 16px 14px}
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
.sunit{font-size:8px;color:var(--text3)}
.dots-sec{padding:0 16px 12px}
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
.df{background:var(--bg3);color:var(--text3)}
.dm{background:rgba(220,38,38,.12);color:var(--red)}
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
.dd{background:rgba(22,163,74,.12);color:var(--green)}
.db{background:rgba(37,99,235,.12);color:var(--blue)}
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
.ldot{width:8px;height:8px;border-radius:2px}
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.dpanel-title{font-size:13px;font-weight:600}
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
.dpanel-body{padding:10px 12px}
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
.set-row:last-of-type{border-bottom:none}
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
.sdel:active{color:var(--red)}
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
.num-in:focus{outline:none;border-color:var(--blue)}
.ulbl{font-size:12px;color:var(--text3)}
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
.btn-as:active{opacity:.7}
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
.btn-del:active{color:var(--red)}
.qb-card{padding:0 16px}
.qb-row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border)}
.qb-row:last-child{border-bottom:none}
.qb-name{flex:1;font-size:16px;font-weight:600;letter-spacing:-.2px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0}
.qb-stat{font-size:11px;font-family:'DM Mono',monospace;color:var(--text3);white-space:nowrap}
.qb-row .num-in{width:62px;padding:6px 6px;font-size:13px}
.qb-row .btn-as{flex:none;padding:8px 14px;font-size:13px}
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
@keyframes fi{from{opacity:0}to{opacity:1}}
.sheet{width:100%;max-width:480px;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.ff{margin-bottom:10px}
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
.fi:focus{outline:none;border-color:var(--blue)}
.factions{display:flex;gap:8px;margin-top:16px}
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
.btn-p:active{opacity:.8}
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
.dbtn:active{background:var(--border2)}
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
.ddanger{color:var(--red)}
.ddiv{height:1px;background:var(--border);margin:12px 0}
.awards{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:0 16px 10px}
.aw{font-size:24px;line-height:1}
.login-err{background:var(--red-bg);color:var(--red);border-radius:var(--rs);padding:10px 12px;font-size:13px;margin-bottom:12px}
.toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;z-index:300;white-space:nowrap;animation:fi .2s ease}
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
.btn-lnk{background:none;border:none;color:var(--text2);font-size:13px;cursor:pointer;padding:4px 8px;text-decoration:underline;text-underline-offset:2px}

View file

@ -35,6 +35,21 @@
"migrations/.gitignore"
]
},
"phpunit/phpunit": {
"version": "13.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"symfony/console": {
"version": "8.0",
"recipe": {

View file

@ -0,0 +1,586 @@
<?php
namespace App\Tests;
use App\Entity\Goal;
use App\Entity\Invite;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class AppIntegrationTest extends WebTestCase
{
private EntityManagerInterface $em;
private KernelBrowser $client;
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->cleanup();
}
protected function tearDown(): void
{
$this->cleanup();
parent::tearDown();
}
private function cleanup(): void
{
$conn = $this->em->getConnection();
$conn->executeStatement("DELETE FROM goals WHERE user_id IN (SELECT id FROM users WHERE email LIKE '%@test.dudi')");
$conn->executeStatement("DELETE FROM invites WHERE created_by IN (SELECT id FROM users WHERE email LIKE '%@test.dudi')");
$conn->executeStatement("DELETE FROM users_resets WHERE user IN (SELECT id FROM users WHERE email LIKE '%@test.dudi')");
$conn->executeStatement("DELETE FROM users WHERE email LIKE '%@test.dudi'");
$this->em->clear();
}
private function createUser(string $suffix = 'main', string $password = 'test1234'): User
{
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
$user = new User();
$user->setEmail($suffix . '@test.dudi')
->setUsername('Tester ' . $suffix)
->setVerified(true);
$user->setPassword($hasher->hashPassword($user, $password));
$this->em->persist($user);
$this->em->flush();
return $user;
}
private function authClient(User $user): KernelBrowser
{
$this->client->loginUser($user);
return $this->client;
}
private function json(KernelBrowser $client, string $method, string $url, array $body = []): array
{
$client->request($method, $url, [], [], ['CONTENT_TYPE' => 'application/json'], $body ? json_encode($body) : null);
return json_decode($client->getResponse()->getContent(), true) ?? [];
}
// ── Auth ─────────────────────────────────────────────────────────────────
public function testLoginSuccess(): void
{
$this->createUser('login', 'geheim99');
$data = $this->json($this->client, 'POST', '/api/login', ['email' => 'login@test.dudi', 'password' => 'geheim99']);
$this->assertTrue($data['ok']);
$this->assertSame('login@test.dudi', $data['email']);
$this->assertSame('Tester login', $data['name']);
}
public function testLoginWrongPassword(): void
{
$this->createUser('login2');
$data = $this->json($this->client, 'POST', '/api/login', ['email' => 'login2@test.dudi', 'password' => 'falsch']);
$this->assertArrayHasKey('error', $data);
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
}
public function testLoginUnknownEmail(): void
{
$data = $this->json($this->client, 'POST', '/api/login', ['email' => 'nobody@test.dudi', 'password' => 'test1234']);
$this->assertArrayHasKey('error', $data);
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
}
public function testMe(): void
{
$user = $this->createUser('me');
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/me');
$this->assertTrue($data['ok']);
$this->assertSame('me@test.dudi', $data['email']);
$this->assertSame('Tester me', $data['name']);
$this->assertIsInt($data['id']);
}
public function testMeUnauthenticated(): void
{
$this->json($this->client, 'GET', '/api/me');
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
}
public function testUpdateName(): void
{
$user = $this->createUser('name');
$client = $this->authClient($user);
$data = $this->json($client, 'PATCH', '/api/me', ['name' => 'Neuer Name']);
$this->assertTrue($data['ok']);
$this->assertSame('Neuer Name', $data['name']);
$this->em->refresh($user);
$this->assertSame('Neuer Name', $user->getUsername());
}
public function testUpdateNameEmpty(): void
{
$user = $this->createUser('nameempty');
$client = $this->authClient($user);
$data = $this->json($client, 'PATCH', '/api/me', ['name' => '']);
$this->assertArrayHasKey('error', $data);
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
}
public function testChangePassword(): void
{
$user = $this->createUser('pw', 'altesPasswort1');
$client = $this->authClient($user);
$data = $this->json($client, 'POST', '/api/change-password', [
'old_password' => 'altesPasswort1',
'new_password' => 'neuesPasswort1',
]);
$this->assertTrue($data['ok']);
// verify new password works
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
$this->em->refresh($user);
$this->assertTrue($hasher->isPasswordValid($user, 'neuesPasswort1'));
}
public function testChangePasswordWrongOld(): void
{
$user = $this->createUser('pwwrong');
$client = $this->authClient($user);
$data = $this->json($client, 'POST', '/api/change-password', [
'old_password' => 'falsch1234',
'new_password' => 'neuesPasswort1',
]);
$this->assertArrayHasKey('error', $data);
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
}
public function testChangePasswordTooShort(): void
{
$user = $this->createUser('pwshort');
$client = $this->authClient($user);
$data = $this->json($client, 'POST', '/api/change-password', [
'old_password' => 'test1234',
'new_password' => 'kurz',
]);
$this->assertArrayHasKey('error', $data);
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
}
// ── Password reset ────────────────────────────────────────────────────────
public function testResetRequestAlwaysReturnsOk(): void
{
// unknown email — still returns ok (don't leak existence)
$data = $this->json($this->client, 'POST', '/api/reset-request', ['email' => 'ghost@test.dudi']);
$this->assertTrue($data['ok']);
}
public function testResetRequestWritesToken(): void
{
$user = $this->createUser('reset');
$this->json($this->client, 'POST', '/api/reset-request', ['email' => 'reset@test.dudi']);
$row = $this->em->getConnection()->fetchAssociative(
'SELECT * FROM users_resets WHERE user = ?',
[$user->getId()]
);
$this->assertNotFalse($row);
$this->assertGreaterThan(time(), $row['expires']);
}
public function testResetPassword(): void
{
$user = $this->createUser('resetpw');
$selector = bin2hex(random_bytes(12));
$token = bin2hex(random_bytes(32));
$hash = password_hash($token, PASSWORD_BCRYPT);
$this->em->getConnection()->executeStatement(
'INSERT INTO users_resets (user, selector, token, expires) VALUES (?, ?, ?, ?)',
[$user->getId(), $selector, $hash, time() + 3600]
);
$data = $this->json($this->client, 'POST', '/api/reset-password', [
'selector' => $selector,
'token' => $token,
'password' => 'neuesPasswort99',
]);
$this->assertTrue($data['ok']);
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
$this->em->refresh($user);
$this->assertTrue($hasher->isPasswordValid($user, 'neuesPasswort99'));
// token must be deleted after use
$row = $this->em->getConnection()->fetchAssociative(
'SELECT * FROM users_resets WHERE selector = ?', [$selector]
);
$this->assertFalse($row);
}
public function testResetPasswordExpiredToken(): void
{
$user = $this->createUser('resetexp');
$selector = bin2hex(random_bytes(12));
$token = bin2hex(random_bytes(32));
$this->em->getConnection()->executeStatement(
'INSERT INTO users_resets (user, selector, token, expires) VALUES (?, ?, ?, ?)',
[$user->getId(), $selector, password_hash($token, PASSWORD_BCRYPT), time() - 1]
);
$data = $this->json($this->client, 'POST', '/api/reset-password', [
'selector' => $selector,
'token' => $token,
'password' => 'neuesPasswort99',
]);
$this->assertArrayHasKey('error', $data);
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
}
// ── Goals ─────────────────────────────────────────────────────────────────
public function testGoalListEmpty(): void
{
$user = $this->createUser('goallist');
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/goals');
$this->assertSame([], $data);
}
public function testGoalCreate(): void
{
$user = $this->createUser('goalcreate');
$client = $this->authClient($user);
$data = $this->json($client, 'POST', '/api/goals', [
'name' => 'Liegestütz',
'unit' => 'Stück',
'daily' => 50,
'days' => 30,
]);
$this->assertSame('Liegestütz', $data['name']);
$this->assertSame('Stück', $data['unit']);
$this->assertEquals(50.0, $data['daily']);
$this->assertSame(30, $data['days']);
$this->assertSame([], $data['sets']);
$this->assertIsString($data['id']);
}
public function testGoalCreateMissingName(): void
{
$user = $this->createUser('goalnoname');
$client = $this->authClient($user);
$data = $this->json($client, 'POST', '/api/goals', ['unit' => 'Stück', 'daily' => 10, 'days' => 7]);
$this->assertArrayHasKey('error', $data);
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
}
public function testGoalDefaultUnit(): void
{
$user = $this->createUser('goalunit');
$client = $this->authClient($user);
$data = $this->json($client, 'POST', '/api/goals', ['name' => 'Plank', 'daily' => 1, 'days' => 7]);
$this->assertSame('Stück', $data['unit']);
}
public function testGoalListReturnsOwned(): void
{
$user = $this->createUser('goalowned');
$other = $this->createUser('goalother');
foreach ([$user, $other] as $u) {
$goal = new Goal();
$goal->setUserId($u->getId())->setName('Ziel')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
$this->em->persist($goal);
}
$this->em->flush();
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/goals');
$this->assertCount(1, $data);
$this->assertSame('Ziel', $data[0]['name']);
}
public function testGoalUpdate(): void
{
$user = $this->createUser('goalupdate');
$goal = new Goal();
$goal->setUserId($user->getId())->setName('Alt')->setUnit('Stück')->setDaily(10)->setDays(7)->setStart(new \DateTime())->setSets([]);
$this->em->persist($goal);
$this->em->flush();
$client = $this->authClient($user);
$data = $this->json($client, 'PATCH', '/api/goals/' . $goal->getId(), [
'name' => 'Neu',
'unit' => 'Min',
'daily' => 20,
]);
$this->assertTrue($data['ok']);
$this->em->refresh($goal);
$this->assertSame('Neu', $goal->getName());
$this->assertSame('Min', $goal->getUnit());
$this->assertSame(20.0, $goal->getDaily());
}
public function testGoalUpdateSets(): void
{
$user = $this->createUser('goalsets');
$goal = new Goal();
$goal->setUserId($user->getId())->setName('Test')->setUnit('Stück')->setDaily(50)->setDays(7)->setStart(new \DateTime())->setSets([]);
$this->em->persist($goal);
$this->em->flush();
$today = date('Y-m-d');
$client = $this->authClient($user);
$data = $this->json($client, 'PATCH', '/api/goals/' . $goal->getId(), [
'sets' => [$today => [20, 30]],
]);
$this->assertTrue($data['ok']);
$this->em->refresh($goal);
$this->assertSame([20, 30], $goal->getSets()[$today]);
}
public function testGoalUpdateNotFound(): void
{
$user = $this->createUser('goalnotfound');
$client = $this->authClient($user);
$data = $this->json($client, 'PATCH', '/api/goals/999999', ['name' => 'X']);
$this->assertArrayHasKey('error', $data);
$this->assertSame(404, $client->getResponse()->getStatusCode());
}
public function testGoalUpdateOwnership(): void
{
$owner = $this->createUser('goalown1');
$other = $this->createUser('goalown2');
$goal = new Goal();
$goal->setUserId($owner->getId())->setName('Privat')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
$this->em->persist($goal);
$this->em->flush();
$client = $this->authClient($other);
$data = $this->json($client, 'PATCH', '/api/goals/' . $goal->getId(), ['name' => 'Gehackt']);
$this->assertSame(404, $client->getResponse()->getStatusCode());
$this->em->refresh($goal);
$this->assertSame('Privat', $goal->getName());
}
public function testGoalDelete(): void
{
$user = $this->createUser('goaldel');
$goal = new Goal();
$goal->setUserId($user->getId())->setName('Weg')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
$this->em->persist($goal);
$this->em->flush();
$id = $goal->getId();
$client = $this->authClient($user);
$data = $this->json($client, 'DELETE', '/api/goals/' . $id);
$this->assertTrue($data['ok']);
$this->assertNull($this->em->find(Goal::class, $id));
}
public function testGoalDeleteOwnership(): void
{
$owner = $this->createUser('goaldel1');
$other = $this->createUser('goaldel2');
$goal = new Goal();
$goal->setUserId($owner->getId())->setName('Privat')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
$this->em->persist($goal);
$this->em->flush();
$id = $goal->getId();
$client = $this->authClient($other);
$this->json($client, 'DELETE', '/api/goals/' . $id);
$this->assertNotNull($this->em->find(Goal::class, $id));
}
public function testGoalUnauthenticated(): void
{
$this->json($this->client, 'GET', '/api/goals');
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
}
// ── Invites ───────────────────────────────────────────────────────────────
public function testInviteCreate(): void
{
$user = $this->createUser('invcreate');
$client = $this->authClient($user);
$data = $this->json($client, 'POST', '/api/invite', ['note' => 'Für Max']);
$this->assertStringContainsString('invite=', $data['url']);
}
public function testInviteList(): void
{
$user = $this->createUser('invlist');
$invite = new Invite();
$invite->setToken(bin2hex(random_bytes(32)))
->setNote('Testeinladung')
->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'));
$this->em->persist($invite);
$this->em->flush();
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/invites');
$this->assertCount(1, $data);
$this->assertSame('pending', $data[0]['status']);
$this->assertSame('Testeinladung', $data[0]['note']);
$this->assertStringContainsString('invite=', $data[0]['url']);
}
public function testInviteListExpired(): void
{
$user = $this->createUser('invexp');
$invite = new Invite();
$invite->setToken(bin2hex(random_bytes(32)))
->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('-1 day'));
$this->em->persist($invite);
$this->em->flush();
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/invites');
$this->assertSame('expired', $data[0]['status']);
$this->assertNull($data[0]['url']);
}
public function testInviteListUsed(): void
{
$user = $this->createUser('invused1');
$newUser = $this->createUser('invused2');
$invite = new Invite();
$invite->setToken(bin2hex(random_bytes(32)))
->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'))
->setUsedBy($newUser->getId())
->setUsedAt(new \DateTimeImmutable());
$this->em->persist($invite);
$this->em->flush();
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/invites');
$this->assertSame('used', $data[0]['status']);
$this->assertSame('invused2@test.dudi', $data[0]['used_by_email']);
$this->assertNull($data[0]['url']);
}
// ── Register ──────────────────────────────────────────────────────────────
public function testRegisterWithInvite(): void
{
$creator = $this->createUser('reginviter');
$invite = new Invite();
$invite->setToken(bin2hex(random_bytes(32)))
->setCreatedBy($creator->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'));
$this->em->persist($invite);
$this->em->flush();
$data = $this->json($this->client, 'POST', '/api/register', [
'email' => 'regnew@test.dudi',
'password' => 'passwort99',
'name' => 'Neuer User',
'token' => $invite->getToken(),
]);
$this->assertTrue($data['ok']);
$this->assertSame('regnew@test.dudi', $data['email']);
$newUser = static::getContainer()->get(\App\Repository\UserRepository::class)->findOneBy(['email' => 'regnew@test.dudi']);
$this->assertNotNull($newUser);
$this->assertTrue($newUser->isVerified());
$this->em->refresh($invite);
$this->assertSame($newUser->getId(), $invite->getUsedBy());
$this->assertNotNull($invite->getUsedAt());
}
public function testRegisterInvalidToken(): void
{
$data = $this->json($this->client, 'POST', '/api/register', [
'email' => 'regbad@test.dudi',
'password' => 'passwort99',
'token' => 'ungueltig',
]);
$this->assertArrayHasKey('error', $data);
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
}
public function testRegisterDuplicateEmail(): void
{
$existing = $this->createUser('regdup');
$creator = $this->createUser('regdupinviter');
$invite = new Invite();
$invite->setToken(bin2hex(random_bytes(32)))
->setCreatedBy($creator->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'));
$this->em->persist($invite);
$this->em->flush();
$data = $this->json($this->client, 'POST', '/api/register', [
'email' => 'regdup@test.dudi',
'password' => 'passwort99',
'token' => $invite->getToken(),
]);
$this->assertArrayHasKey('error', $data);
$this->assertSame(409, $this->client->getResponse()->getStatusCode());
}
public function testRegisterPasswordTooShort(): void
{
$creator = $this->createUser('regshortinviter');
$invite = new Invite();
$invite->setToken(bin2hex(random_bytes(32)))
->setCreatedBy($creator->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'));
$this->em->persist($invite);
$this->em->flush();
$data = $this->json($this->client, 'POST', '/api/register', [
'email' => 'regshort@test.dudi',
'password' => 'kurz',
'token' => $invite->getToken(),
]);
$this->assertArrayHasKey('error', $data);
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
}
}

13
tests/bootstrap.php Normal file
View file

@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}