diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..64bd111 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' diff --git a/.gitignore b/.gitignore index 3ba2448..576fac8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ /.aider* /docs/private/ ###< tools ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### diff --git a/api.php b/api.php deleted file mode 100644 index b7788b1..0000000 --- a/api.php +++ /dev/null @@ -1,294 +0,0 @@ - $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); diff --git a/app.js b/app.js deleted file mode 100644 index 97a60e2..0000000 --- a/app.js +++ /dev/null @@ -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=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;i0?'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;i0?' 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) 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=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.1.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4991e47adce8e31e554aee8fdaabfc3b1d60707d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4991e47adce8e31e554aee8fdaabfc3b1d60707d", + "reference": "4991e47adce8e31e554aee8fdaabfc3b1d60707d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-04-24T13:10:08+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.1.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddd6401641861cdef94b922ef10d484f436e8dcd", + "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.1.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.1.2", + "sebastian/diff": "^8.1.0", + "sebastian/environment": "^9.3.0", + "sebastian/exporter": "^8.0.2", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.0", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.1-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.7" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-04-18T06:14:52+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", + "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.1", + "sebastian/exporter": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-04-14T08:24:42+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "9c957d730257f49c873f3761674559bd90098a7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d", + "reference": "9c957d730257f49c873f3761674559bd90098a7d", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-04-05T12:02:33+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6767059a30e4277ac95ee034809e793528464768" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6767059a30e4277ac95ee034809e793528464768", + "reference": "6767059a30e4277ac95ee034809e793528464768", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-04-15T12:14:03+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-04-15T12:38:05+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:13+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:54+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:09+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f5a28fca785416cf489dd579011e74c831100cc3", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "284ace90732b445b027728b5e0eec6418a17a364" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", + "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": ">=8.4", - "ext-ctype": "*", - "ext-iconv": "*" + "ext-ctype": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.9.0" } diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index ffb8ee9..62fe33e 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -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: diff --git a/config/reference.php b/config/reference.php index 7defcec..b89e2ae 100644 --- a/config/reference.php +++ b/config/reference.php @@ -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, * "when@prod"?: array, * "when@test"?: array, * ... diff --git a/include/db.php b/include/db.php deleted file mode 100644 index c81ca34..0000000 --- a/include/db.php +++ /dev/null @@ -1,60 +0,0 @@ - '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]); - } -} diff --git a/include/mailer.php b/include/mailer.php deleted file mode 100644 index 3e9e12d..0000000 --- a/include/mailer.php +++ /dev/null @@ -1,19 +0,0 @@ -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(); -} diff --git a/index.php b/index.php deleted file mode 100644 index a2c8b46..0000000 --- a/index.php +++ /dev/null @@ -1,8 +0,0 @@ - false] -); -echo $twig->render('app.html.twig'); diff --git a/logo.png b/logo.png deleted file mode 100644 index a194b03..0000000 Binary files a/logo.png and /dev/null differ diff --git a/migrations/Version20260430000000.php b/migrations/Version20260430000000.php new file mode 100644 index 0000000..76450e1 --- /dev/null +++ b/migrations/Version20260430000000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..22bd879 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + diff --git a/style.css b/style.css deleted file mode 100644 index 6252ca9..0000000 --- a/style.css +++ /dev/null @@ -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} diff --git a/symfony.lock b/symfony.lock index 5295bca..572246c 100644 --- a/symfony.lock +++ b/symfony.lock @@ -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": { diff --git a/tests/AppIntegrationTest.php b/tests/AppIntegrationTest.php new file mode 100644 index 0000000..a40131e --- /dev/null +++ b/tests/AppIntegrationTest.php @@ -0,0 +1,586 @@ +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()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..47a5855 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +}