Add PHPUnit integration tests, remove legacy pre-Symfony files, fix password reset
- Delete legacy root files (api.php, index.php, app.js, style.css, logo.png, include/) - Install symfony/test-pack, add 34 integration tests covering auth, goals, invites, register, password reset - Fix bug: users_resets.selector was varchar(20) but controller generates 24-char selectors; widen to varchar(64) - Remove doctrine dbname_suffix from test env (tests run against live DB, cleanup via tearDown) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6503466344
commit
80e418f8b7
19 changed files with 2768 additions and 1232 deletions
3
.env.test
Normal file
3
.env.test
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# define your env variables for the test env here
|
||||||
|
KERNEL_CLASS='App\Kernel'
|
||||||
|
APP_SECRET='$ecretf0rt3st'
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -16,3 +16,8 @@
|
||||||
/.aider*
|
/.aider*
|
||||||
/docs/private/
|
/docs/private/
|
||||||
###< tools ###
|
###< tools ###
|
||||||
|
|
||||||
|
###> phpunit/phpunit ###
|
||||||
|
/phpunit.xml
|
||||||
|
/.phpunit.cache/
|
||||||
|
###< phpunit/phpunit ###
|
||||||
|
|
|
||||||
294
api.php
294
api.php
|
|
@ -1,294 +0,0 @@
|
||||||
<?php
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
ini_set('display_errors', 0);
|
|
||||||
ini_set('log_errors', 1);
|
|
||||||
ini_set('error_log', '/tmp/zt.log');
|
|
||||||
set_exception_handler(function(\Throwable $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['error' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
|
|
||||||
exit;
|
|
||||||
});
|
|
||||||
require_once __DIR__ . '/include/db.php';
|
|
||||||
require_once __DIR__ . '/include/mailer.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
|
|
||||||
// CORS für gleiche Domain nicht nötig; bei separaten Dev-Domains ggf. ergänzen
|
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
|
||||||
$segments = explode('/', trim($_GET['_path'] ?? '', '/'));
|
|
||||||
$resource = $segments[0] ?? '';
|
|
||||||
$resourceId = isset($segments[1]) && $segments[1] !== '' ? (int)$segments[1] : null;
|
|
||||||
|
|
||||||
|
|
||||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
||||||
|
|
||||||
function json_out(array $data, int $status = 200): never {
|
|
||||||
http_response_code($status);
|
|
||||||
echo json_encode($data);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function require_auth(\Delight\Auth\Auth $auth): int {
|
|
||||||
if (!$auth->isLoggedIn()) {
|
|
||||||
json_out(['error' => 'Unauthorized'], 401);
|
|
||||||
}
|
|
||||||
return $auth->getUserId();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/login ──────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'login') {
|
|
||||||
$email = trim($body['email'] ?? '');
|
|
||||||
$pass = $body['password'] ?? '';
|
|
||||||
try {
|
|
||||||
$auth->login($email, $pass, 86400);
|
|
||||||
json_out(['ok' => true, 'email' => $auth->getEmail()]);
|
|
||||||
} catch (\Delight\Auth\InvalidEmailException
|
|
||||||
| \Delight\Auth\InvalidPasswordException
|
|
||||||
| \Delight\Auth\UserNotFound $e) {
|
|
||||||
json_out(['error' => 'Falsche E-Mail oder Passwort'], 401);
|
|
||||||
} catch (\Delight\Auth\TooManyRequestsException $e) {
|
|
||||||
json_out(['error' => 'Zu viele Versuche, bitte warten'], 429);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/logout ─────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'logout') {
|
|
||||||
$auth->logOut();
|
|
||||||
json_out(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /api/me ───────────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'GET' && $resource === 'me') {
|
|
||||||
if ($auth->isLoggedIn()) {
|
|
||||||
$name = $conn->fetchOne('SELECT username FROM users WHERE id = ?', [$auth->getUserId()]);
|
|
||||||
json_out(['ok' => true, 'email' => $auth->getEmail(), 'id' => $auth->getUserId(), 'name' => $name ?: '']);
|
|
||||||
}
|
|
||||||
json_out(['ok' => false], 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PATCH /api/me ─────────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'PATCH' && $resource === 'me') {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
$name = trim($body['name'] ?? '');
|
|
||||||
if (!$name) json_out(['error' => 'Name fehlt'], 400);
|
|
||||||
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $uid]);
|
|
||||||
json_out(['ok' => true, 'name' => $name]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/register ────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'register') {
|
|
||||||
$email = trim($body['email'] ?? '');
|
|
||||||
$pass = $body['password'] ?? '';
|
|
||||||
$token = trim($body['token'] ?? '');
|
|
||||||
|
|
||||||
if (!$email || !$pass || !$token) {
|
|
||||||
json_out(['error' => 'Fehlende Felder'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token validieren
|
|
||||||
$invite = $conn->fetchAssociative(
|
|
||||||
'SELECT * FROM invites WHERE token = ? AND used_by IS NULL AND expires_at > NOW()',
|
|
||||||
[$token]
|
|
||||||
);
|
|
||||||
if (!$invite) {
|
|
||||||
json_out(['error' => 'Ungültiger oder abgelaufener Einladungslink'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = trim($body['name'] ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$userId = $auth->register($email, $pass);
|
|
||||||
} catch (\Delight\Auth\InvalidEmailException $e) {
|
|
||||||
json_out(['error' => 'Ungültige E-Mail'], 400);
|
|
||||||
} catch (\Delight\Auth\InvalidPasswordException $e) {
|
|
||||||
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
|
|
||||||
} catch (\Delight\Auth\UserAlreadyExistsException $e) {
|
|
||||||
json_out(['error' => 'E-Mail bereits registriert'], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token als verwendet markieren
|
|
||||||
$conn->executeStatement(
|
|
||||||
'UPDATE invites SET used_by = ?, used_at = NOW() WHERE token = ?',
|
|
||||||
[$userId, $token]
|
|
||||||
);
|
|
||||||
if ($name) {
|
|
||||||
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $userId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direkt einloggen
|
|
||||||
$auth->login($email, $pass, 86400);
|
|
||||||
json_out(['ok' => true, 'email' => $auth->getEmail(), 'name' => $name]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/reset-request ──────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'reset-request') {
|
|
||||||
$email = trim($body['email'] ?? '');
|
|
||||||
if (!$email) json_out(['error' => 'E-Mail fehlt'], 400);
|
|
||||||
try {
|
|
||||||
$auth->requestPasswordReset($email, function (string $selector, string $token) use ($email) {
|
|
||||||
$url = APP_URL . '/?reset_selector=' . rawurlencode($selector) . '&reset_token=' . rawurlencode($token);
|
|
||||||
$text = "Hallo,\n\nklicke auf den folgenden Link um dein Passwort zurückzusetzen (gültig 24h):\n\n$url\n\nFalls du das nicht angefragt hast, ignoriere diese E-Mail.";
|
|
||||||
sendMail($email, 'Durchzieh-Dienst – Passwort zurücksetzen', $text);
|
|
||||||
});
|
|
||||||
} catch (\Delight\Auth\InvalidEmailException $e) {
|
|
||||||
// Keinen Hinweis geben ob die E-Mail existiert
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
json_out(['error' => 'Mail konnte nicht gesendet werden'], 500);
|
|
||||||
}
|
|
||||||
json_out(['ok' => true]); // immer ok, auch wenn E-Mail unbekannt
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/reset-password ──────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'reset-password') {
|
|
||||||
$selector = $body['selector'] ?? '';
|
|
||||||
$token = $body['token'] ?? '';
|
|
||||||
$pass = $body['password'] ?? '';
|
|
||||||
if (!$selector || !$token || !$pass) json_out(['error' => 'Fehlende Felder'], 400);
|
|
||||||
try {
|
|
||||||
$auth->resetPassword($selector, $token, $pass);
|
|
||||||
json_out(['ok' => true]);
|
|
||||||
} catch (\Delight\Auth\InvalidSelectorTokenPairException $e) {
|
|
||||||
json_out(['error' => 'Ungültiger oder abgelaufener Reset-Link'], 400);
|
|
||||||
} catch (\Delight\Auth\TokenExpiredException $e) {
|
|
||||||
json_out(['error' => 'Reset-Link abgelaufen, bitte neu anfordern'], 400);
|
|
||||||
} catch (\Delight\Auth\InvalidPasswordException $e) {
|
|
||||||
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/change-password ─────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'change-password') {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
$oldPass = $body['old_password'] ?? '';
|
|
||||||
$newPass = $body['new_password'] ?? '';
|
|
||||||
if (!$oldPass || !$newPass) json_out(['error' => 'Fehlende Felder'], 400);
|
|
||||||
try {
|
|
||||||
$auth->changePassword($oldPass, $newPass);
|
|
||||||
json_out(['ok' => true]);
|
|
||||||
} catch (\Delight\Auth\NotLoggedInException $e) {
|
|
||||||
json_out(['error' => 'Nicht eingeloggt'], 401);
|
|
||||||
} catch (\Delight\Auth\InvalidPasswordException $e) {
|
|
||||||
json_out(['error' => 'Neues Passwort zu kurz (min. 8 Zeichen)'], 400);
|
|
||||||
} catch (\Delight\Auth\TooManyRequestsException $e) {
|
|
||||||
json_out(['error' => 'Zu viele Versuche'], 429);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/invite ─────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'invite') {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
$token = bin2hex(random_bytes(32));
|
|
||||||
$note = trim($body['note'] ?? '');
|
|
||||||
$conn->executeStatement(
|
|
||||||
'INSERT INTO invites (token, note, created_by, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))',
|
|
||||||
[$token, $note ?: null, $uid]
|
|
||||||
);
|
|
||||||
json_out(['url' => APP_URL . '/?invite=' . $token]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /api/invites ──────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'GET' && $resource === 'invites') {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
$rows = $conn->fetchAllAssociative(
|
|
||||||
'SELECT i.token, i.created_at, i.expires_at, i.used_at, i.note, u.email AS used_by_email
|
|
||||||
FROM invites i
|
|
||||||
LEFT JOIN users u ON u.id = i.used_by
|
|
||||||
WHERE i.created_by = ?
|
|
||||||
ORDER BY i.created_at DESC',
|
|
||||||
[$uid]
|
|
||||||
);
|
|
||||||
$result = array_map(function ($r) {
|
|
||||||
if ($r['used_by_email'] !== null) {
|
|
||||||
$status = 'used';
|
|
||||||
} elseif (strtotime($r['expires_at']) < time()) {
|
|
||||||
$status = 'expired';
|
|
||||||
} else {
|
|
||||||
$status = 'pending';
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
'url' => $status === 'pending' ? APP_URL . '/?invite=' . $r['token'] : null,
|
|
||||||
'created_at' => $r['created_at'],
|
|
||||||
'expires_at' => $r['expires_at'],
|
|
||||||
'used_at' => $r['used_at'],
|
|
||||||
'note' => $r['note'],
|
|
||||||
'used_by_email' => $r['used_by_email'],
|
|
||||||
'status' => $status,
|
|
||||||
];
|
|
||||||
}, $rows);
|
|
||||||
json_out($result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /api/goals ────────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'GET' && $resource === 'goals') {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
$rows = $conn->fetchAllAssociative(
|
|
||||||
'SELECT id, name, unit, daily, days, start, sets FROM goals WHERE user_id = ? ORDER BY created_at ASC',
|
|
||||||
[$uid]
|
|
||||||
);
|
|
||||||
$goals = array_map(function ($r) {
|
|
||||||
$r['sets'] = json_decode($r['sets'], true) ?: [];
|
|
||||||
$r['id'] = (string)$r['id'];
|
|
||||||
return $r;
|
|
||||||
}, $rows);
|
|
||||||
json_out($goals);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/goals ───────────────────────────────────────────────────────────
|
|
||||||
if ($method === 'POST' && $resource === 'goals') {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
$name = trim($body['name'] ?? '');
|
|
||||||
$unit = trim($body['unit'] ?? 'Stück');
|
|
||||||
$daily = (float)($body['daily'] ?? 1);
|
|
||||||
$days = (int) ($body['days'] ?? 30);
|
|
||||||
try { $start = (new DateTime($body['start'] ?? 'now'))->format('Y-m-d H:i:s'); }
|
|
||||||
catch (\Exception $e) { $start = date('Y-m-d H:i:s'); }
|
|
||||||
$setsJson = isset($body['sets']) && is_array($body['sets']) ? json_encode((object)$body['sets']) : '{}';
|
|
||||||
if (!$name) json_out(['error' => 'Name fehlt'], 400);
|
|
||||||
|
|
||||||
$conn->executeStatement(
|
|
||||||
'INSERT INTO goals (user_id, name, unit, daily, days, start, sets) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[$uid, $name, $unit, $daily, $days, $start, $setsJson]
|
|
||||||
);
|
|
||||||
$id = (string)$conn->lastInsertId();
|
|
||||||
json_out(['id' => $id, 'name' => $name, 'unit' => $unit, 'daily' => $daily, 'days' => $days, 'start' => $start, 'sets' => json_decode($setsJson, true)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PATCH /api/goals/{id} ─────────────────────────────────────────────────────
|
|
||||||
if ($method === 'PATCH' && $resource === 'goals' && $resourceId !== null) {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
// Sicherstellen dass das Ziel dem User gehört
|
|
||||||
$existing = $conn->fetchOne('SELECT id FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
|
|
||||||
if (!$existing) json_out(['error' => 'Nicht gefunden'], 404);
|
|
||||||
|
|
||||||
$fields = [];
|
|
||||||
$params = [];
|
|
||||||
foreach (['name', 'unit'] as $f) {
|
|
||||||
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (string)$body[$f]; }
|
|
||||||
}
|
|
||||||
foreach (['daily'] as $f) {
|
|
||||||
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (float)$body[$f]; }
|
|
||||||
}
|
|
||||||
foreach (['days'] as $f) {
|
|
||||||
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (int)$body[$f]; }
|
|
||||||
}
|
|
||||||
if (isset($body['sets'])) {
|
|
||||||
$fields[] = 'sets = ?';
|
|
||||||
$params[] = json_encode($body['sets']);
|
|
||||||
}
|
|
||||||
if (!$fields) json_out(['ok' => true]);
|
|
||||||
|
|
||||||
$params[] = $resourceId;
|
|
||||||
$params[] = $uid;
|
|
||||||
$conn->executeStatement('UPDATE goals SET ' . implode(', ', $fields) . ' WHERE id = ? AND user_id = ?', $params);
|
|
||||||
json_out(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DELETE /api/goals/{id} ────────────────────────────────────────────────────
|
|
||||||
if ($method === 'DELETE' && $resource === 'goals' && $resourceId !== null) {
|
|
||||||
$uid = require_auth($auth);
|
|
||||||
$conn->executeStatement('DELETE FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
|
|
||||||
json_out(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
json_out(['error' => 'Not found'], 404);
|
|
||||||
716
app.js
716
app.js
|
|
@ -1,716 +0,0 @@
|
||||||
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
|
|
||||||
var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {};
|
|
||||||
var userName = '';
|
|
||||||
|
|
||||||
function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
|
|
||||||
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
|
|
||||||
prefs = loadPref('zt_p','{}');
|
|
||||||
|
|
||||||
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
|
|
||||||
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
|
|
||||||
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
|
|
||||||
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
|
|
||||||
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
|
|
||||||
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
|
|
||||||
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
|
|
||||||
|
|
||||||
function heuteColor(tdone,daily){
|
|
||||||
if(tdone===0) return 'var(--red)';
|
|
||||||
if(tdone>=daily*1.1) return 'var(--blue)';
|
|
||||||
if(tdone>=daily) return 'var(--green)';
|
|
||||||
return 'var(--amber)';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCollapsed(id){ return collapsed[id]!==false; }
|
|
||||||
function toggleCollapse(id){
|
|
||||||
var wasCollapsed=isCollapsed(id);
|
|
||||||
collapsed[id]=!wasCollapsed;
|
|
||||||
if(wasCollapsed){
|
|
||||||
var g=goals.filter(function(x){return x.id===id;})[0];
|
|
||||||
if(g) selDay[id]=tOff(g);
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function calc(g){
|
|
||||||
var t=tOff(g), tot=g.daily*g.days;
|
|
||||||
var dr=Math.max(0,g.days-t-1);
|
|
||||||
var sd=new Date(g.start); sd.setHours(0,0,0,0);
|
|
||||||
var end=new Date(sd.getTime()+g.days*86400000);
|
|
||||||
var past=0;
|
|
||||||
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
|
|
||||||
var tdone=dTot(g,t), tot2=past+tdone;
|
|
||||||
var dl=dr+1;
|
|
||||||
var remaining=Math.max(0,tot-past);
|
|
||||||
var pd=Math.ceil(remaining/Math.max(1,dl));
|
|
||||||
var st=Math.max(0,pd-tdone);
|
|
||||||
var expectedPast=Math.min(t,g.days)*g.daily;
|
|
||||||
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
|
|
||||||
var deficit=Math.min(0,buf);
|
|
||||||
var surplus=Math.max(0,buf);
|
|
||||||
var dailyDelta=pd-g.daily;
|
|
||||||
var pct=Math.min(100,Math.round((tot2/tot)*100));
|
|
||||||
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
|
|
||||||
}
|
|
||||||
|
|
||||||
function dcls(g,i){
|
|
||||||
var t=tOff(g); if(i>t) return 'dot df';
|
|
||||||
var v=dTot(g,i);
|
|
||||||
var c=v===0?'dot dm':v>=g.daily*1.1?'dot db':v>=g.daily?'dot dd':'dot dp';
|
|
||||||
return c+(editable(g,i)?' de':' dl');
|
|
||||||
}
|
|
||||||
function dlbl(g,i){
|
|
||||||
var t=tOff(g); if(i>t) return String(i+1);
|
|
||||||
var v=dTot(g,i);
|
|
||||||
if(v===0) return '✕'; if(v>=g.daily*1.1) return '+'; if(v>=g.daily) return '✓';
|
|
||||||
return Math.round(v/g.daily*100)+'%';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function api(method, path, body){
|
|
||||||
var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}};
|
|
||||||
if(body) opts.body = JSON.stringify(body);
|
|
||||||
return fetch('api/' + path, opts).then(function(res){
|
|
||||||
return res.json().then(function(data){
|
|
||||||
if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; }
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadGoals(){
|
|
||||||
return api('GET','goals').then(function(data){ return data; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveGoal(g){
|
|
||||||
api('PATCH','goals/'+g.id,{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets})
|
|
||||||
.catch(function(e){
|
|
||||||
if(e.status===401){ showLogin(); }
|
|
||||||
else showToast('Speicherfehler');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showToast(msg){
|
|
||||||
var t=document.createElement('div'); t.className='toast'; t.textContent=msg;
|
|
||||||
document.body.appendChild(t); setTimeout(function(){t.remove();},3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Goal-Aktionen ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function addSet(gid,off){
|
|
||||||
var g=goals.filter(function(x){return x.id===gid;})[0];
|
|
||||||
if(!g||!editable(g,off)) return;
|
|
||||||
var k=gid+'_'+off, amt=parseInt(addAmt[k]||'0',10);
|
|
||||||
if(amt<=0) return;
|
|
||||||
if(!g.sets[String(off)]) g.sets[String(off)]=[];
|
|
||||||
g.sets[String(off)].push({amount:amt,time:off===tOff(g)?now():'—'});
|
|
||||||
addAmt[k]=''; saveGoal(g); render();
|
|
||||||
}
|
|
||||||
function remSet(gid,off,idx){
|
|
||||||
var g=goals.filter(function(x){return x.id===gid;})[0];
|
|
||||||
if(!g||!editable(g,off)) return;
|
|
||||||
g.sets[String(off)].splice(idx,1); saveGoal(g); render();
|
|
||||||
}
|
|
||||||
function delGoal(id){
|
|
||||||
if(!confirm('Ziel wirklich löschen?')) return;
|
|
||||||
goals=goals.filter(function(g){return g.id!==id;});
|
|
||||||
render();
|
|
||||||
api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); });
|
|
||||||
}
|
|
||||||
function selD(gid,off){
|
|
||||||
var g=goals.filter(function(x){return x.id===gid;})[0];
|
|
||||||
if(!g||!editable(g,off)) return;
|
|
||||||
selDay[gid]=selDay[gid]===off?null:off; render();
|
|
||||||
}
|
|
||||||
function startRen(id){
|
|
||||||
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
|
|
||||||
renamingId=id; renameVal=g.name; render();
|
|
||||||
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
|
|
||||||
}
|
|
||||||
function commitRen(id){
|
|
||||||
var g=goals.filter(function(x){return x.id===id;})[0];
|
|
||||||
if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);}
|
|
||||||
renamingId=null; render();
|
|
||||||
}
|
|
||||||
function cancelRen(){ renamingId=null; render(); }
|
|
||||||
|
|
||||||
// ── Template-Helper ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function tpl(id){
|
|
||||||
return document.getElementById(id).content.cloneNode(true).firstElementChild;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Overlays ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var OV_CSS='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;justify-content:center;animation:fi .2s ease';
|
|
||||||
|
|
||||||
function closeOv(){
|
|
||||||
var o=document.getElementById('ov');
|
|
||||||
o.style.display='none';
|
|
||||||
o.innerHTML='';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSheet(content, dismissable){
|
|
||||||
var o=document.getElementById('ov');
|
|
||||||
o.style.cssText=OV_CSS;
|
|
||||||
var sheet=tpl('tpl-sheet');
|
|
||||||
sheet.appendChild(content);
|
|
||||||
o.innerHTML='';
|
|
||||||
o.appendChild(sheet);
|
|
||||||
o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Login ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showLogin(err){
|
|
||||||
var c=tpl('tpl-login');
|
|
||||||
if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; }
|
|
||||||
showSheet(c,false);
|
|
||||||
var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub');
|
|
||||||
setTimeout(function(){email.focus();},50);
|
|
||||||
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
|
|
||||||
pass.onkeydown=function(e){if(e.key==='Enter')sub.click();};
|
|
||||||
c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();};
|
|
||||||
sub.onclick=function(){
|
|
||||||
var ev=email.value.trim(), pv=pass.value;
|
|
||||||
if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; }
|
|
||||||
sub.disabled=true; sub.textContent='…';
|
|
||||||
api('POST','login',{email:ev,password:pv})
|
|
||||||
.then(function(){ return loadGoals(); })
|
|
||||||
.then(function(g){ goals=g; closeOv(); render(); })
|
|
||||||
.catch(function(err){
|
|
||||||
sub.disabled=false; sub.textContent='Anmelden';
|
|
||||||
showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Passwort vergessen ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showForgotPassword(){
|
|
||||||
var c=tpl('tpl-forgot-pw');
|
|
||||||
showSheet(c,false);
|
|
||||||
var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub');
|
|
||||||
setTimeout(function(){email.focus();},50);
|
|
||||||
c.querySelector('.fp-back').onclick=function(){showLogin();};
|
|
||||||
sub.onclick=function(){
|
|
||||||
var ev=email.value.trim(); if(!ev) return;
|
|
||||||
sub.disabled=true; sub.textContent='…';
|
|
||||||
api('POST','reset-request',{email:ev})
|
|
||||||
.then(function(){
|
|
||||||
var conf=tpl('tpl-email-sent');
|
|
||||||
conf.querySelector('.es-ok').onclick=function(){showLogin();};
|
|
||||||
showSheet(conf,false);
|
|
||||||
})
|
|
||||||
.catch(function(err){
|
|
||||||
sub.disabled=false; sub.textContent='Link senden';
|
|
||||||
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Passwort zurücksetzen ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showResetPassword(selector,token){
|
|
||||||
var c=tpl('tpl-reset-pw');
|
|
||||||
showSheet(c,false);
|
|
||||||
var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub');
|
|
||||||
setTimeout(function(){pass.focus();},50);
|
|
||||||
sub.onclick=function(){
|
|
||||||
var pv=pass.value; if(!pv) return;
|
|
||||||
sub.disabled=true; sub.textContent='…';
|
|
||||||
api('POST','reset-password',{selector:selector,token:token,password:pv})
|
|
||||||
.then(function(){
|
|
||||||
var conf=tpl('tpl-pw-changed');
|
|
||||||
conf.querySelector('.pc-ok').onclick=function(){showLogin();};
|
|
||||||
showSheet(conf,false);
|
|
||||||
})
|
|
||||||
.catch(function(err){
|
|
||||||
sub.disabled=false; sub.textContent='Passwort setzen';
|
|
||||||
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Passwort ändern ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showChangePassword(){
|
|
||||||
var c=tpl('tpl-change-pw');
|
|
||||||
showSheet(c,true);
|
|
||||||
var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2');
|
|
||||||
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub');
|
|
||||||
setTimeout(function(){oldP.focus();},50);
|
|
||||||
c.querySelector('.cp-can').onclick=closeOv;
|
|
||||||
sub.onclick=function(){
|
|
||||||
var o=oldP.value, n=newP.value, n2=newP2.value;
|
|
||||||
if(!o||!n||!n2) return;
|
|
||||||
if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
|
|
||||||
sub.disabled=true; sub.textContent='…';
|
|
||||||
api('POST','change-password',{old_password:o,new_password:n})
|
|
||||||
.then(function(){ showToast('Passwort geändert'); closeOv(); })
|
|
||||||
.catch(function(err){
|
|
||||||
sub.disabled=false; sub.textContent='Ändern';
|
|
||||||
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registrierung ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showRegister(token){
|
|
||||||
var c=tpl('tpl-register');
|
|
||||||
showSheet(c,false);
|
|
||||||
var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email');
|
|
||||||
var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2');
|
|
||||||
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub');
|
|
||||||
setTimeout(function(){nameInp.focus();},50);
|
|
||||||
nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();};
|
|
||||||
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
|
|
||||||
pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();};
|
|
||||||
pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();};
|
|
||||||
function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } }
|
|
||||||
pass.oninput=checkMatch; pass2.oninput=checkMatch;
|
|
||||||
sub.onclick=function(){
|
|
||||||
var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value;
|
|
||||||
if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; }
|
|
||||||
if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
|
|
||||||
sub.disabled=true; sub.textContent='…';
|
|
||||||
api('POST','register',{name:nv,email:ev,password:pv,token:token})
|
|
||||||
.then(function(r){ userName=r.name||''; return loadGoals(); })
|
|
||||||
.then(function(g){ goals=g; closeOv(); updateHeader(); render(); })
|
|
||||||
.catch(function(err){
|
|
||||||
sub.disabled=false; sub.textContent='Registrieren';
|
|
||||||
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Neues Ziel ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function openNew(){
|
|
||||||
var c=tpl('tpl-new-goal');
|
|
||||||
showSheet(c,true);
|
|
||||||
var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit');
|
|
||||||
var daily=c.querySelector('.ng-daily'), days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub');
|
|
||||||
setTimeout(function(){name.focus();},50);
|
|
||||||
c.querySelector('.ng-can').onclick=closeOv;
|
|
||||||
sub.onclick=function(){
|
|
||||||
var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück';
|
|
||||||
var dv=parseInt(daily.value,10)||1, dyv=parseInt(days.value,10)||30;
|
|
||||||
if(!nv){ name.focus(); return; }
|
|
||||||
sub.disabled=true;
|
|
||||||
api('POST','goals',{name:nv,unit:uv,daily:dv,days:dyv,start:TODAY.toISOString()})
|
|
||||||
.then(function(r){
|
|
||||||
goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}});
|
|
||||||
closeOv(); render();
|
|
||||||
}).catch(function(e){
|
|
||||||
sub.disabled=false;
|
|
||||||
if(e.status===401){ closeOv(); showLogin(); }
|
|
||||||
else showToast('Fehler beim Erstellen');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Daten-Menü ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function openData(){
|
|
||||||
var c=tpl('tpl-data-menu');
|
|
||||||
showSheet(c,true);
|
|
||||||
c.querySelector('.dm-cls').onclick=closeOv;
|
|
||||||
c.querySelector('.dm-name').onclick=function(){
|
|
||||||
var nc=tpl('tpl-change-name');
|
|
||||||
showSheet(nc,true);
|
|
||||||
var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub');
|
|
||||||
inp.value=userName;
|
|
||||||
setTimeout(function(){inp.focus();inp.select();},50);
|
|
||||||
nc.querySelector('.cn-can').onclick=closeOv;
|
|
||||||
sub.onclick=function(){
|
|
||||||
var nv=inp.value.trim();
|
|
||||||
if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; }
|
|
||||||
sub.disabled=true; sub.textContent='…';
|
|
||||||
api('PATCH','me',{name:nv})
|
|
||||||
.then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); })
|
|
||||||
.catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); });
|
|
||||||
};
|
|
||||||
};
|
|
||||||
c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); };
|
|
||||||
c.querySelector('.dm-lgout').onclick=function(){
|
|
||||||
api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); });
|
|
||||||
};
|
|
||||||
|
|
||||||
c.querySelector('.dm-inv').onclick=function(){
|
|
||||||
var ic=tpl('tpl-invite-form');
|
|
||||||
showSheet(ic,true);
|
|
||||||
var invName=ic.querySelector('.inv-name');
|
|
||||||
setTimeout(function(){invName.focus();},50);
|
|
||||||
ic.querySelector('.inv-cancel').onclick=closeOv;
|
|
||||||
ic.querySelector('.inv-gen').onclick=function(){
|
|
||||||
var note=invName.value.trim(), btn=this;
|
|
||||||
btn.disabled=true; btn.textContent='…';
|
|
||||||
api('POST','invite',{note:note}).then(function(res){
|
|
||||||
var lc=tpl('tpl-invite-link');
|
|
||||||
lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:'');
|
|
||||||
var urlInp=lc.querySelector('.il-url');
|
|
||||||
urlInp.value=res.url;
|
|
||||||
showSheet(lc,true);
|
|
||||||
lc.querySelector('.il-close').onclick=closeOv;
|
|
||||||
lc.querySelector('.il-copy').onclick=function(){
|
|
||||||
navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); });
|
|
||||||
};
|
|
||||||
setTimeout(function(){urlInp.select();},50);
|
|
||||||
}).catch(function(){
|
|
||||||
btn.disabled=false; btn.textContent='Link generieren';
|
|
||||||
showToast('Fehler beim Generieren');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
c.querySelector('.dm-invlist').onclick=function(){
|
|
||||||
api('GET','invites').then(function(list){
|
|
||||||
var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'};
|
|
||||||
var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'};
|
|
||||||
var lc=tpl('tpl-invite-list');
|
|
||||||
var body=lc.querySelector('.dpanel-body');
|
|
||||||
if(!list.length){
|
|
||||||
var empty=document.createElement('div');
|
|
||||||
empty.className='nosets'; empty.style.padding='16px';
|
|
||||||
empty.textContent='Noch keine Einladungen verschickt';
|
|
||||||
body.appendChild(empty);
|
|
||||||
} else {
|
|
||||||
for(var i=0;i<list.length;i++){
|
|
||||||
var inv=list[i];
|
|
||||||
var label=inv.note||new Date(inv.created_at).toLocaleDateString('de-DE',{day:'numeric',month:'short',year:'numeric'});
|
|
||||||
var detail=inv.used_by_email?('→ '+inv.used_by_email):(inv.status==='pending'?'läuft ab: '+new Date(inv.expires_at).toLocaleDateString('de-DE',{day:'numeric',month:'short'}):'');
|
|
||||||
var row=tpl('tpl-invite-row');
|
|
||||||
row.querySelector('.ir-label').textContent=label;
|
|
||||||
if(detail) row.querySelector('.ir-detail').textContent=' '+detail;
|
|
||||||
var st=row.querySelector('.ir-status');
|
|
||||||
st.textContent=statusLabel[inv.status]; st.style.color=statusColor[inv.status];
|
|
||||||
if(inv.url){
|
|
||||||
var cp=row.querySelector('.ir-copy'); cp.style.display='';
|
|
||||||
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast('Link kopiert!'); }); }; }(inv.url);
|
|
||||||
}
|
|
||||||
body.appendChild(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showSheet(lc,true);
|
|
||||||
lc.querySelector('.il-close').onclick=closeOv;
|
|
||||||
}).catch(function(){ showToast('Fehler beim Laden'); });
|
|
||||||
};
|
|
||||||
|
|
||||||
c.querySelector('.dm-exp').onclick=function(){
|
|
||||||
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
|
|
||||||
var url=URL.createObjectURL(blob), a=document.createElement('a');
|
|
||||||
a.href=url; a.download='dudi-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
|
|
||||||
};
|
|
||||||
|
|
||||||
c.querySelector('.dm-imp').onclick=function(){
|
|
||||||
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
|
|
||||||
inp.onchange=function(e){
|
|
||||||
var f=e.target.files[0]; if(!f) return;
|
|
||||||
var r=new FileReader(); r.onload=function(ev){
|
|
||||||
try{
|
|
||||||
var p=JSON.parse(ev.target.result);
|
|
||||||
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
|
|
||||||
if(!confirm(p.goals.length+' Ziel(e) importieren?')) return;
|
|
||||||
var promises=p.goals.map(function(g){
|
|
||||||
return api('POST','goals',{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets||{}})
|
|
||||||
.then(function(r){ goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}}); });
|
|
||||||
});
|
|
||||||
Promise.all(promises).then(function(){ closeOv(); render(); alert(p.goals.length+' Ziel(e) importiert.'); });
|
|
||||||
}catch(err){ alert('Fehler: '+err.message); }
|
|
||||||
}; r.readAsText(f);
|
|
||||||
}; inp.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
c.querySelector('.dm-clr').onclick=function(){
|
|
||||||
if(!confirm('Alle Daten löschen?')) return;
|
|
||||||
var ids=goals.map(function(g){return g.id;}); goals=[]; render();
|
|
||||||
Promise.all(ids.map(function(id){return api('DELETE','goals/'+id);}))
|
|
||||||
.catch(function(){ showToast('Fehler beim Löschen'); });
|
|
||||||
closeOv();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Card-Bausteine ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildNameWrap(g){
|
|
||||||
if(renamingId===g.id){
|
|
||||||
var el=tpl('tpl-name-edit');
|
|
||||||
var inp=el.querySelector('.ren-input');
|
|
||||||
inp.id='ri'+g.id; inp.value=g.name; inp.dataset.g=g.id;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
var el=tpl('tpl-name-view');
|
|
||||||
el.querySelector('.goal-name').textContent=g.name;
|
|
||||||
el.querySelector('.btn-ren').dataset.g=g.id;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPanel(g,off){
|
|
||||||
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
|
|
||||||
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
|
|
||||||
var el=tpl('tpl-panel');
|
|
||||||
el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off));
|
|
||||||
el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit;
|
|
||||||
var body=el.querySelector('.dpanel-body');
|
|
||||||
if(sets.length){
|
|
||||||
for(var i=0;i<sets.length;i++){
|
|
||||||
var s=sets[i], row=tpl('tpl-set-row'), span=row.querySelector('span');
|
|
||||||
if(s.time!=='—'){
|
|
||||||
var st=document.createElement('span'); st.className='stime'; st.textContent=s.time+' ·';
|
|
||||||
span.appendChild(st); span.appendChild(document.createTextNode(' '));
|
|
||||||
}
|
|
||||||
var strong=document.createElement('strong'); strong.textContent=s.amount;
|
|
||||||
span.appendChild(strong); span.appendChild(document.createTextNode(' '+g.unit));
|
|
||||||
var btn=row.querySelector('.sdel');
|
|
||||||
btn.dataset.g=g.id; btn.dataset.o=off; btn.dataset.i=i;
|
|
||||||
body.appendChild(row);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body.appendChild(tpl('tpl-nosets'));
|
|
||||||
}
|
|
||||||
var addRow=tpl('tpl-add-row');
|
|
||||||
var inp=addRow.querySelector('.num-in');
|
|
||||||
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=off;
|
|
||||||
var abtn=addRow.querySelector('.btn-as');
|
|
||||||
abtn.dataset.g=g.id; abtn.dataset.o=off;
|
|
||||||
addRow.querySelector('.ulbl').textContent=g.unit;
|
|
||||||
body.appendChild(addRow);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCard(g){
|
|
||||||
var c=calc(g), t=c.tOff;
|
|
||||||
var fc=c.surplus>0?'var(--blue)':c.st===0?'var(--green)':c.dailyDelta<=0?'var(--green)':c.dailyDelta<=g.daily*.2?'var(--amber)':'var(--red)';
|
|
||||||
var bc,bt,bufStr=(c.buf>0?'+':'')+c.buf;
|
|
||||||
if(c.ok&&c.surplus>0){bc='b-buf';bt=bufStr;}
|
|
||||||
else if(c.ok){bc='b-done';bt=bufStr;}
|
|
||||||
else if(c.dailyDelta<=0){bc='b-ok';bt=bufStr;}
|
|
||||||
else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt=bufStr;}
|
|
||||||
else{bc='b-danger';bt=bufStr;}
|
|
||||||
|
|
||||||
var el;
|
|
||||||
if(isCollapsed(g.id)){
|
|
||||||
el=tpl('tpl-card-collapsed');
|
|
||||||
if(c.ok) el.classList.add('done');
|
|
||||||
el.querySelector('.card-hdr').dataset.g=g.id;
|
|
||||||
var bd=el.querySelector('.card-bd');
|
|
||||||
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
|
|
||||||
var hc=heuteColor(c.tdone,g.daily);
|
|
||||||
el.querySelector('.m-dr').textContent=c.dr;
|
|
||||||
el.querySelector('.m-end').textContent=fs(c.end);
|
|
||||||
var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc;
|
|
||||||
el.querySelector('.m-total').textContent=c.done+'/'+c.tot;
|
|
||||||
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
|
|
||||||
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
el=tpl('tpl-card-expanded');
|
|
||||||
if(c.ok) el.classList.add('done');
|
|
||||||
el.querySelector('.card-hdr').dataset.g=g.id;
|
|
||||||
var bd=el.querySelector('.card-bd');
|
|
||||||
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
|
|
||||||
el.querySelector('.m-dr').textContent=c.dr;
|
|
||||||
el.querySelector('.m-end').textContent=fs(c.end);
|
|
||||||
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
|
|
||||||
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
|
|
||||||
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht';
|
|
||||||
el.querySelector('.pr-pct').textContent=c.pct+'% von '+c.tot;
|
|
||||||
el.querySelector('.sv-tdone').textContent=c.tdone;
|
|
||||||
el.querySelector('.sv-daily').textContent=g.daily;
|
|
||||||
el.querySelector('.sv-st').textContent=c.st;
|
|
||||||
el.querySelector('.sv-noch').style.color=heuteColor(c.tdone,g.daily);
|
|
||||||
el.querySelectorAll('.sunit').forEach(function(u){ u.textContent=g.unit; });
|
|
||||||
|
|
||||||
var sel=selDay[g.id]!=null?selDay[g.id]:t;
|
|
||||||
var dotsWrap=el.querySelector('.dots-wrap');
|
|
||||||
for(var i=0;i<g.days;i++){
|
|
||||||
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
|
|
||||||
var dot=tpl('tpl-dot');
|
|
||||||
dot.className=dcls(g,i)+(is?' rs':it?' rt':iy&&t>0?' ry':'');
|
|
||||||
if(ed){ dot.dataset.g=g.id; dot.dataset.d=i; }
|
|
||||||
dot.textContent=dlbl(g,i);
|
|
||||||
dotsWrap.appendChild(dot);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(sel!=null) el.insertBefore(buildPanel(g,sel),el.querySelector('.card-foot'));
|
|
||||||
el.querySelector('.btn-del').dataset.g=g.id;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quick-Buchen ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildQuickBook(){
|
|
||||||
var active=goals.filter(function(g){ var c=calc(g); return tOff(g)<g.days&&!c.ok; });
|
|
||||||
if(!active.length) return null;
|
|
||||||
var frag=document.createDocumentFragment();
|
|
||||||
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent='Quick-Buchen';
|
|
||||||
frag.appendChild(lbl);
|
|
||||||
var card=document.createElement('div'); card.className='card qb-card';
|
|
||||||
for(var i=0;i<active.length;i++){
|
|
||||||
var g=active[i], c=calc(g), k=g.id+'_'+c.tOff;
|
|
||||||
var row=tpl('tpl-qb-row');
|
|
||||||
row.querySelector('.qb-name').textContent=g.name;
|
|
||||||
var stat=row.querySelector('.qb-stat'); stat.textContent=c.tdone+'/'+g.daily; stat.style.color=heuteColor(c.tdone,g.daily);
|
|
||||||
var inp=row.querySelector('.num-in');
|
|
||||||
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=c.tOff;
|
|
||||||
var btn=row.querySelector('.btn-as'); btn.dataset.g=g.id; btn.dataset.o=c.tOff;
|
|
||||||
card.appendChild(row);
|
|
||||||
}
|
|
||||||
frag.appendChild(card);
|
|
||||||
return frag;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function calcAwards(){
|
|
||||||
var units=0;
|
|
||||||
for(var i=0;i<goals.length;i++){
|
|
||||||
var g=goals[i];
|
|
||||||
if(tOff(g)>=g.days) units+=Math.floor(g.days/30);
|
|
||||||
}
|
|
||||||
var gold=Math.floor(units/25); units%=25;
|
|
||||||
var silver=Math.floor(units/5); var bronze=units%5;
|
|
||||||
return{gold:gold,silver:silver,bronze:bronze};
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(){
|
|
||||||
var m=document.getElementById('main');
|
|
||||||
var frag=document.createDocumentFragment();
|
|
||||||
|
|
||||||
if(!prefs.hd){
|
|
||||||
var hint=tpl('tpl-hint');
|
|
||||||
hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); };
|
|
||||||
frag.appendChild(hint);
|
|
||||||
}
|
|
||||||
|
|
||||||
var aw=calcAwards();
|
|
||||||
if(aw.gold||aw.silver||aw.bronze){
|
|
||||||
var awards=document.createElement('div'); awards.className='awards';
|
|
||||||
var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]];
|
|
||||||
for(var mi=0;mi<medals.length;mi++){
|
|
||||||
for(var ai=0;ai<medals[mi][1];ai++){
|
|
||||||
var sp=document.createElement('span'); sp.className='aw'; sp.textContent=medals[mi][0];
|
|
||||||
awards.appendChild(sp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frag.appendChild(awards);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!goals.length){
|
|
||||||
frag.appendChild(tpl('tpl-empty'));
|
|
||||||
m.innerHTML=''; m.appendChild(frag); wire(); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(userName){
|
|
||||||
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent='Hallo '+userName+'!';
|
|
||||||
frag.appendChild(gr);
|
|
||||||
}
|
|
||||||
|
|
||||||
var qb=buildQuickBook(); if(qb) frag.appendChild(qb);
|
|
||||||
|
|
||||||
var open=[],done=[];
|
|
||||||
for(var gi=0;gi<goals.length;gi++){
|
|
||||||
var g=goals[gi], c=calc(g);
|
|
||||||
if(c.ok) done.push(g); else open.push(g);
|
|
||||||
}
|
|
||||||
if(open.length){
|
|
||||||
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent='Offen';
|
|
||||||
frag.appendChild(sl);
|
|
||||||
for(var i=0;i<open.length;i++) frag.appendChild(buildCard(open[i]));
|
|
||||||
}
|
|
||||||
if(done.length){
|
|
||||||
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent='Heute erledigt';
|
|
||||||
frag.appendChild(sl2);
|
|
||||||
for(var j=0;j<done.length;j++) frag.appendChild(buildCard(done[j]));
|
|
||||||
}
|
|
||||||
|
|
||||||
m.innerHTML=''; m.appendChild(frag); wire();
|
|
||||||
}
|
|
||||||
|
|
||||||
function wire(){
|
|
||||||
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
|
|
||||||
el.onclick=function(e){
|
|
||||||
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
|
|
||||||
toggleCollapse(this.dataset.g);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.btn-ren').forEach(function(b){
|
|
||||||
b.onclick=function(e){e.stopPropagation();startRen(this.dataset.g);};
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.ren-input').forEach(function(inp){
|
|
||||||
var gid=inp.dataset.g;
|
|
||||||
inp.oninput=function(){renameVal=this.value;};
|
|
||||||
inp.onkeydown=function(e){if(e.key==='Enter')commitRen(gid);if(e.key==='Escape')cancelRen();};
|
|
||||||
inp.onblur=function(){commitRen(gid);};
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.de').forEach(function(d){
|
|
||||||
d.onclick=function(e){e.stopPropagation();selD(this.dataset.g,parseInt(this.dataset.d,10));};
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.btn-as').forEach(function(b){
|
|
||||||
b.onclick=function(){addSet(this.dataset.g,parseInt(this.dataset.o,10));};
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.num-in').forEach(function(inp){
|
|
||||||
var k=inp.dataset.k, g=inp.dataset.g, o=parseInt(inp.dataset.o,10);
|
|
||||||
inp.oninput=function(){addAmt[k]=this.value;};
|
|
||||||
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.sdel').forEach(function(b){
|
|
||||||
b.onclick=function(){remSet(this.dataset.g,parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.btn-del').forEach(function(b){
|
|
||||||
b.onclick=function(){delGoal(this.dataset.g);};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function updateHeader(){
|
|
||||||
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btnNew').onclick=openNew;
|
|
||||||
document.getElementById('btnData').onclick=openData;
|
|
||||||
updateHeader();
|
|
||||||
|
|
||||||
var _qs=new URLSearchParams(window.location.search);
|
|
||||||
var inviteToken=_qs.get('invite');
|
|
||||||
var resetSelector=_qs.get('reset_selector');
|
|
||||||
var resetToken=_qs.get('reset_token');
|
|
||||||
if(inviteToken||resetSelector) history.replaceState(null,'',location.pathname);
|
|
||||||
|
|
||||||
if(resetSelector&&resetToken){
|
|
||||||
render(); showResetPassword(resetSelector,resetToken);
|
|
||||||
} else {
|
|
||||||
api('GET','me')
|
|
||||||
.then(function(r){ userName=r.name||''; updateHeader(); return loadGoals(); })
|
|
||||||
.then(function(g){ goals=g; render(); })
|
|
||||||
.catch(function(){
|
|
||||||
render();
|
|
||||||
if(inviteToken){ showRegister(inviteToken); }
|
|
||||||
else { showLogin(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleMidnight(){
|
|
||||||
var n=new Date();
|
|
||||||
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
|
|
||||||
setTimeout(function(){
|
|
||||||
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
|
|
||||||
updateHeader();render();scheduleMidnight();
|
|
||||||
},ms);
|
|
||||||
}
|
|
||||||
scheduleMidnight();
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange',function(){
|
|
||||||
if(document.visibilityState==='visible'){
|
|
||||||
var n=new Date();n.setHours(0,0,0,0);
|
|
||||||
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
|
|
||||||
loadGoals().then(function(g){goals=g;render();}).catch(function(){});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
4
bin/phpunit
Executable file
4
bin/phpunit
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||||
|
|
@ -62,5 +62,10 @@
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "8.0.*"
|
"require": "8.0.*"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^13.1",
|
||||||
|
"symfony/browser-kit": "8.0.*",
|
||||||
|
"symfony/css-selector": "8.0.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2073
composer.lock
generated
2073
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -19,11 +19,6 @@ doctrine:
|
||||||
prefix: 'App\Entity'
|
prefix: 'App\Entity'
|
||||||
alias: App
|
alias: App
|
||||||
|
|
||||||
when@test:
|
|
||||||
doctrine:
|
|
||||||
dbal:
|
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
|
|
||||||
|
|
@ -1240,16 +1240,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
* security?: SecurityConfig,
|
* security?: SecurityConfig,
|
||||||
* twig?: TwigConfig,
|
* 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{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
|
|
@ -1352,7 +1342,6 @@ namespace Symfony\Component\Routing\Loader\Configurator;
|
||||||
* deprecated?: array{package:string, version:string, message?:string},
|
* deprecated?: array{package:string, version:string, message?:string},
|
||||||
* }
|
* }
|
||||||
* @psalm-type RoutesConfig = array{
|
* @psalm-type RoutesConfig = array{
|
||||||
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
|
||||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
|
||||||
require_once __DIR__ . '/config.php';
|
|
||||||
|
|
||||||
$conn = \Doctrine\DBAL\DriverManager::getConnection([
|
|
||||||
'driver' => 'pdo_mysql',
|
|
||||||
'host' => DB_HOST,
|
|
||||||
'dbname' => DB_NAME,
|
|
||||||
'user' => DB_USER,
|
|
||||||
'password' => DB_PASS,
|
|
||||||
'charset' => 'utf8mb4',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// PDO für delight-im/auth
|
|
||||||
$pdo = $conn->getNativeConnection();
|
|
||||||
$auth = new \Delight\Auth\Auth($pdo);
|
|
||||||
|
|
||||||
// Tabellen anlegen falls nicht vorhanden
|
|
||||||
$conn->executeStatement('
|
|
||||||
CREATE TABLE IF NOT EXISTS goals (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
unit VARCHAR(50) NOT NULL DEFAULT \'Stück\',
|
|
||||||
daily FLOAT NOT NULL DEFAULT 1,
|
|
||||||
days INT NOT NULL DEFAULT 30,
|
|
||||||
start DATETIME NOT NULL,
|
|
||||||
sets JSON NOT NULL DEFAULT (\'[]\'),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
');
|
|
||||||
|
|
||||||
$conn->executeStatement('
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version VARCHAR(64) NOT NULL PRIMARY KEY,
|
|
||||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
');
|
|
||||||
|
|
||||||
$conn->executeStatement('
|
|
||||||
CREATE TABLE IF NOT EXISTS invites (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
token VARCHAR(64) NOT NULL UNIQUE,
|
|
||||||
note VARCHAR(255) DEFAULT NULL,
|
|
||||||
created_by INT NOT NULL,
|
|
||||||
used_by INT DEFAULT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
used_at TIMESTAMP DEFAULT NULL
|
|
||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
');
|
|
||||||
|
|
||||||
// Migrations ausführen
|
|
||||||
foreach (glob(__DIR__ . '/../migrations/*.sql') as $file) {
|
|
||||||
$version = basename($file, '.sql');
|
|
||||||
if (!$conn->fetchOne('SELECT 1 FROM schema_migrations WHERE version = ?', [$version])) {
|
|
||||||
$conn->executeStatement(file_get_contents($file));
|
|
||||||
$conn->executeStatement('INSERT INTO schema_migrations (version) VALUES (?)', [$version]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<?php
|
|
||||||
use PHPMailer\PHPMailer\PHPMailer;
|
|
||||||
|
|
||||||
function sendMail(string $to, string $subject, string $body): void {
|
|
||||||
$mail = new PHPMailer(true);
|
|
||||||
$mail->isSMTP();
|
|
||||||
$mail->Host = SMTP_HOST;
|
|
||||||
$mail->SMTPAuth = true;
|
|
||||||
$mail->Username = SMTP_USER;
|
|
||||||
$mail->Password = SMTP_PASS;
|
|
||||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
|
||||||
$mail->Port = SMTP_PORT;
|
|
||||||
$mail->CharSet = 'UTF-8';
|
|
||||||
$mail->setFrom(SMTP_FROM, SMTP_FROM_NAME);
|
|
||||||
$mail->addAddress($to);
|
|
||||||
$mail->Subject = $subject;
|
|
||||||
$mail->Body = $body;
|
|
||||||
$mail->send();
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
|
||||||
|
|
||||||
$twig = new \Twig\Environment(
|
|
||||||
new \Twig\Loader\FilesystemLoader(__DIR__ . '/templates'),
|
|
||||||
['cache' => false]
|
|
||||||
);
|
|
||||||
echo $twig->render('app.html.twig');
|
|
||||||
BIN
logo.png
BIN
logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 228 KiB |
26
migrations/Version20260430000000.php
Normal file
26
migrations/Version20260430000000.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260430000000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Fix users_resets.selector column size (bin2hex(random_bytes(12)) produces 24 chars, was varchar(20))';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE users_resets CHANGE selector selector VARCHAR(64) NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE users_resets CHANGE selector selector VARCHAR(20) NOT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
44
phpunit.dist.xml
Normal file
44
phpunit.dist.xml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
colors="true"
|
||||||
|
failOnDeprecation="true"
|
||||||
|
failOnNotice="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1" />
|
||||||
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
|
</php>
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Project Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
|
ignoreIndirectDeprecations="true"
|
||||||
|
restrictNotices="true"
|
||||||
|
restrictWarnings="true"
|
||||||
|
>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
|
||||||
|
<deprecationTrigger>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||||
|
<function>trigger_deprecation</function>
|
||||||
|
</deprecationTrigger>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
</extensions>
|
||||||
|
</phpunit>
|
||||||
113
style.css
113
style.css
|
|
@ -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}
|
|
||||||
15
symfony.lock
15
symfony.lock
|
|
@ -35,6 +35,21 @@
|
||||||
"migrations/.gitignore"
|
"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": {
|
"symfony/console": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
|
||||||
586
tests/AppIntegrationTest.php
Normal file
586
tests/AppIntegrationTest.php
Normal file
|
|
@ -0,0 +1,586 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Entity\Goal;
|
||||||
|
use App\Entity\Invite;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
class AppIntegrationTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->client = static::createClient();
|
||||||
|
$this->em = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->cleanup();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanup(): void
|
||||||
|
{
|
||||||
|
$conn = $this->em->getConnection();
|
||||||
|
$conn->executeStatement("DELETE FROM goals WHERE user_id IN (SELECT id FROM users WHERE email LIKE '%@test.dudi')");
|
||||||
|
$conn->executeStatement("DELETE FROM invites WHERE created_by IN (SELECT id FROM users WHERE email LIKE '%@test.dudi')");
|
||||||
|
$conn->executeStatement("DELETE FROM users_resets WHERE user IN (SELECT id FROM users WHERE email LIKE '%@test.dudi')");
|
||||||
|
$conn->executeStatement("DELETE FROM users WHERE email LIKE '%@test.dudi'");
|
||||||
|
$this->em->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUser(string $suffix = 'main', string $password = 'test1234'): User
|
||||||
|
{
|
||||||
|
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail($suffix . '@test.dudi')
|
||||||
|
->setUsername('Tester ' . $suffix)
|
||||||
|
->setVerified(true);
|
||||||
|
$user->setPassword($hasher->hashPassword($user, $password));
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authClient(User $user): KernelBrowser
|
||||||
|
{
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function json(KernelBrowser $client, string $method, string $url, array $body = []): array
|
||||||
|
{
|
||||||
|
$client->request($method, $url, [], [], ['CONTENT_TYPE' => 'application/json'], $body ? json_encode($body) : null);
|
||||||
|
return json_decode($client->getResponse()->getContent(), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testLoginSuccess(): void
|
||||||
|
{
|
||||||
|
$this->createUser('login', 'geheim99');
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/login', ['email' => 'login@test.dudi', 'password' => 'geheim99']);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
$this->assertSame('login@test.dudi', $data['email']);
|
||||||
|
$this->assertSame('Tester login', $data['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginWrongPassword(): void
|
||||||
|
{
|
||||||
|
$this->createUser('login2');
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/login', ['email' => 'login2@test.dudi', 'password' => 'falsch']);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginUnknownEmail(): void
|
||||||
|
{
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/login', ['email' => 'nobody@test.dudi', 'password' => 'test1234']);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMe(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('me');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'GET', '/api/me');
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
$this->assertSame('me@test.dudi', $data['email']);
|
||||||
|
$this->assertSame('Tester me', $data['name']);
|
||||||
|
$this->assertIsInt($data['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMeUnauthenticated(): void
|
||||||
|
{
|
||||||
|
$this->json($this->client, 'GET', '/api/me');
|
||||||
|
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateName(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('name');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'PATCH', '/api/me', ['name' => 'Neuer Name']);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
$this->assertSame('Neuer Name', $data['name']);
|
||||||
|
|
||||||
|
$this->em->refresh($user);
|
||||||
|
$this->assertSame('Neuer Name', $user->getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateNameEmpty(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('nameempty');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'PATCH', '/api/me', ['name' => '']);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChangePassword(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('pw', 'altesPasswort1');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'POST', '/api/change-password', [
|
||||||
|
'old_password' => 'altesPasswort1',
|
||||||
|
'new_password' => 'neuesPasswort1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
|
||||||
|
// verify new password works
|
||||||
|
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
$this->em->refresh($user);
|
||||||
|
$this->assertTrue($hasher->isPasswordValid($user, 'neuesPasswort1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChangePasswordWrongOld(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('pwwrong');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'POST', '/api/change-password', [
|
||||||
|
'old_password' => 'falsch1234',
|
||||||
|
'new_password' => 'neuesPasswort1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChangePasswordTooShort(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('pwshort');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'POST', '/api/change-password', [
|
||||||
|
'old_password' => 'test1234',
|
||||||
|
'new_password' => 'kurz',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password reset ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testResetRequestAlwaysReturnsOk(): void
|
||||||
|
{
|
||||||
|
// unknown email — still returns ok (don't leak existence)
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/reset-request', ['email' => 'ghost@test.dudi']);
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResetRequestWritesToken(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('reset');
|
||||||
|
$this->json($this->client, 'POST', '/api/reset-request', ['email' => 'reset@test.dudi']);
|
||||||
|
|
||||||
|
$row = $this->em->getConnection()->fetchAssociative(
|
||||||
|
'SELECT * FROM users_resets WHERE user = ?',
|
||||||
|
[$user->getId()]
|
||||||
|
);
|
||||||
|
$this->assertNotFalse($row);
|
||||||
|
$this->assertGreaterThan(time(), $row['expires']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResetPassword(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('resetpw');
|
||||||
|
|
||||||
|
$selector = bin2hex(random_bytes(12));
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$hash = password_hash($token, PASSWORD_BCRYPT);
|
||||||
|
$this->em->getConnection()->executeStatement(
|
||||||
|
'INSERT INTO users_resets (user, selector, token, expires) VALUES (?, ?, ?, ?)',
|
||||||
|
[$user->getId(), $selector, $hash, time() + 3600]
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/reset-password', [
|
||||||
|
'selector' => $selector,
|
||||||
|
'token' => $token,
|
||||||
|
'password' => 'neuesPasswort99',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
|
||||||
|
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
$this->em->refresh($user);
|
||||||
|
$this->assertTrue($hasher->isPasswordValid($user, 'neuesPasswort99'));
|
||||||
|
|
||||||
|
// token must be deleted after use
|
||||||
|
$row = $this->em->getConnection()->fetchAssociative(
|
||||||
|
'SELECT * FROM users_resets WHERE selector = ?', [$selector]
|
||||||
|
);
|
||||||
|
$this->assertFalse($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResetPasswordExpiredToken(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('resetexp');
|
||||||
|
$selector = bin2hex(random_bytes(12));
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$this->em->getConnection()->executeStatement(
|
||||||
|
'INSERT INTO users_resets (user, selector, token, expires) VALUES (?, ?, ?, ?)',
|
||||||
|
[$user->getId(), $selector, password_hash($token, PASSWORD_BCRYPT), time() - 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/reset-password', [
|
||||||
|
'selector' => $selector,
|
||||||
|
'token' => $token,
|
||||||
|
'password' => 'neuesPasswort99',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Goals ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testGoalListEmpty(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goallist');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'GET', '/api/goals');
|
||||||
|
|
||||||
|
$this->assertSame([], $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalCreate(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goalcreate');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'POST', '/api/goals', [
|
||||||
|
'name' => 'Liegestütz',
|
||||||
|
'unit' => 'Stück',
|
||||||
|
'daily' => 50,
|
||||||
|
'days' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('Liegestütz', $data['name']);
|
||||||
|
$this->assertSame('Stück', $data['unit']);
|
||||||
|
$this->assertEquals(50.0, $data['daily']);
|
||||||
|
$this->assertSame(30, $data['days']);
|
||||||
|
$this->assertSame([], $data['sets']);
|
||||||
|
$this->assertIsString($data['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalCreateMissingName(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goalnoname');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'POST', '/api/goals', ['unit' => 'Stück', 'daily' => 10, 'days' => 7]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalDefaultUnit(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goalunit');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'POST', '/api/goals', ['name' => 'Plank', 'daily' => 1, 'days' => 7]);
|
||||||
|
|
||||||
|
$this->assertSame('Stück', $data['unit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalListReturnsOwned(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goalowned');
|
||||||
|
$other = $this->createUser('goalother');
|
||||||
|
|
||||||
|
foreach ([$user, $other] as $u) {
|
||||||
|
$goal = new Goal();
|
||||||
|
$goal->setUserId($u->getId())->setName('Ziel')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
|
||||||
|
$this->em->persist($goal);
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'GET', '/api/goals');
|
||||||
|
|
||||||
|
$this->assertCount(1, $data);
|
||||||
|
$this->assertSame('Ziel', $data[0]['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalUpdate(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goalupdate');
|
||||||
|
$goal = new Goal();
|
||||||
|
$goal->setUserId($user->getId())->setName('Alt')->setUnit('Stück')->setDaily(10)->setDays(7)->setStart(new \DateTime())->setSets([]);
|
||||||
|
$this->em->persist($goal);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'PATCH', '/api/goals/' . $goal->getId(), [
|
||||||
|
'name' => 'Neu',
|
||||||
|
'unit' => 'Min',
|
||||||
|
'daily' => 20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
$this->em->refresh($goal);
|
||||||
|
$this->assertSame('Neu', $goal->getName());
|
||||||
|
$this->assertSame('Min', $goal->getUnit());
|
||||||
|
$this->assertSame(20.0, $goal->getDaily());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalUpdateSets(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goalsets');
|
||||||
|
$goal = new Goal();
|
||||||
|
$goal->setUserId($user->getId())->setName('Test')->setUnit('Stück')->setDaily(50)->setDays(7)->setStart(new \DateTime())->setSets([]);
|
||||||
|
$this->em->persist($goal);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'PATCH', '/api/goals/' . $goal->getId(), [
|
||||||
|
'sets' => [$today => [20, 30]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
$this->em->refresh($goal);
|
||||||
|
$this->assertSame([20, 30], $goal->getSets()[$today]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalUpdateNotFound(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goalnotfound');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'PATCH', '/api/goals/999999', ['name' => 'X']);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(404, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalUpdateOwnership(): void
|
||||||
|
{
|
||||||
|
$owner = $this->createUser('goalown1');
|
||||||
|
$other = $this->createUser('goalown2');
|
||||||
|
|
||||||
|
$goal = new Goal();
|
||||||
|
$goal->setUserId($owner->getId())->setName('Privat')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
|
||||||
|
$this->em->persist($goal);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$client = $this->authClient($other);
|
||||||
|
$data = $this->json($client, 'PATCH', '/api/goals/' . $goal->getId(), ['name' => 'Gehackt']);
|
||||||
|
|
||||||
|
$this->assertSame(404, $client->getResponse()->getStatusCode());
|
||||||
|
$this->em->refresh($goal);
|
||||||
|
$this->assertSame('Privat', $goal->getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalDelete(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('goaldel');
|
||||||
|
$goal = new Goal();
|
||||||
|
$goal->setUserId($user->getId())->setName('Weg')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
|
||||||
|
$this->em->persist($goal);
|
||||||
|
$this->em->flush();
|
||||||
|
$id = $goal->getId();
|
||||||
|
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'DELETE', '/api/goals/' . $id);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
$this->assertNull($this->em->find(Goal::class, $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalDeleteOwnership(): void
|
||||||
|
{
|
||||||
|
$owner = $this->createUser('goaldel1');
|
||||||
|
$other = $this->createUser('goaldel2');
|
||||||
|
|
||||||
|
$goal = new Goal();
|
||||||
|
$goal->setUserId($owner->getId())->setName('Privat')->setUnit('Stück')->setDaily(1)->setDays(7)->setStart(new \DateTime())->setSets([]);
|
||||||
|
$this->em->persist($goal);
|
||||||
|
$this->em->flush();
|
||||||
|
$id = $goal->getId();
|
||||||
|
|
||||||
|
$client = $this->authClient($other);
|
||||||
|
$this->json($client, 'DELETE', '/api/goals/' . $id);
|
||||||
|
|
||||||
|
$this->assertNotNull($this->em->find(Goal::class, $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoalUnauthenticated(): void
|
||||||
|
{
|
||||||
|
$this->json($this->client, 'GET', '/api/goals');
|
||||||
|
$this->assertSame(401, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invites ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testInviteCreate(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('invcreate');
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'POST', '/api/invite', ['note' => 'Für Max']);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('invite=', $data['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInviteList(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('invlist');
|
||||||
|
|
||||||
|
$invite = new Invite();
|
||||||
|
$invite->setToken(bin2hex(random_bytes(32)))
|
||||||
|
->setNote('Testeinladung')
|
||||||
|
->setCreatedBy($user->getId())
|
||||||
|
->setExpiresAt(new \DateTimeImmutable('+7 days'));
|
||||||
|
$this->em->persist($invite);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'GET', '/api/invites');
|
||||||
|
|
||||||
|
$this->assertCount(1, $data);
|
||||||
|
$this->assertSame('pending', $data[0]['status']);
|
||||||
|
$this->assertSame('Testeinladung', $data[0]['note']);
|
||||||
|
$this->assertStringContainsString('invite=', $data[0]['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInviteListExpired(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('invexp');
|
||||||
|
|
||||||
|
$invite = new Invite();
|
||||||
|
$invite->setToken(bin2hex(random_bytes(32)))
|
||||||
|
->setCreatedBy($user->getId())
|
||||||
|
->setExpiresAt(new \DateTimeImmutable('-1 day'));
|
||||||
|
$this->em->persist($invite);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'GET', '/api/invites');
|
||||||
|
|
||||||
|
$this->assertSame('expired', $data[0]['status']);
|
||||||
|
$this->assertNull($data[0]['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInviteListUsed(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('invused1');
|
||||||
|
$newUser = $this->createUser('invused2');
|
||||||
|
|
||||||
|
$invite = new Invite();
|
||||||
|
$invite->setToken(bin2hex(random_bytes(32)))
|
||||||
|
->setCreatedBy($user->getId())
|
||||||
|
->setExpiresAt(new \DateTimeImmutable('+7 days'))
|
||||||
|
->setUsedBy($newUser->getId())
|
||||||
|
->setUsedAt(new \DateTimeImmutable());
|
||||||
|
$this->em->persist($invite);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$client = $this->authClient($user);
|
||||||
|
$data = $this->json($client, 'GET', '/api/invites');
|
||||||
|
|
||||||
|
$this->assertSame('used', $data[0]['status']);
|
||||||
|
$this->assertSame('invused2@test.dudi', $data[0]['used_by_email']);
|
||||||
|
$this->assertNull($data[0]['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Register ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testRegisterWithInvite(): void
|
||||||
|
{
|
||||||
|
$creator = $this->createUser('reginviter');
|
||||||
|
|
||||||
|
$invite = new Invite();
|
||||||
|
$invite->setToken(bin2hex(random_bytes(32)))
|
||||||
|
->setCreatedBy($creator->getId())
|
||||||
|
->setExpiresAt(new \DateTimeImmutable('+7 days'));
|
||||||
|
$this->em->persist($invite);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/register', [
|
||||||
|
'email' => 'regnew@test.dudi',
|
||||||
|
'password' => 'passwort99',
|
||||||
|
'name' => 'Neuer User',
|
||||||
|
'token' => $invite->getToken(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($data['ok']);
|
||||||
|
$this->assertSame('regnew@test.dudi', $data['email']);
|
||||||
|
|
||||||
|
$newUser = static::getContainer()->get(\App\Repository\UserRepository::class)->findOneBy(['email' => 'regnew@test.dudi']);
|
||||||
|
$this->assertNotNull($newUser);
|
||||||
|
$this->assertTrue($newUser->isVerified());
|
||||||
|
|
||||||
|
$this->em->refresh($invite);
|
||||||
|
$this->assertSame($newUser->getId(), $invite->getUsedBy());
|
||||||
|
$this->assertNotNull($invite->getUsedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegisterInvalidToken(): void
|
||||||
|
{
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/register', [
|
||||||
|
'email' => 'regbad@test.dudi',
|
||||||
|
'password' => 'passwort99',
|
||||||
|
'token' => 'ungueltig',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegisterDuplicateEmail(): void
|
||||||
|
{
|
||||||
|
$existing = $this->createUser('regdup');
|
||||||
|
|
||||||
|
$creator = $this->createUser('regdupinviter');
|
||||||
|
$invite = new Invite();
|
||||||
|
$invite->setToken(bin2hex(random_bytes(32)))
|
||||||
|
->setCreatedBy($creator->getId())
|
||||||
|
->setExpiresAt(new \DateTimeImmutable('+7 days'));
|
||||||
|
$this->em->persist($invite);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/register', [
|
||||||
|
'email' => 'regdup@test.dudi',
|
||||||
|
'password' => 'passwort99',
|
||||||
|
'token' => $invite->getToken(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(409, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegisterPasswordTooShort(): void
|
||||||
|
{
|
||||||
|
$creator = $this->createUser('regshortinviter');
|
||||||
|
$invite = new Invite();
|
||||||
|
$invite->setToken(bin2hex(random_bytes(32)))
|
||||||
|
->setCreatedBy($creator->getId())
|
||||||
|
->setExpiresAt(new \DateTimeImmutable('+7 days'));
|
||||||
|
$this->em->persist($invite);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$data = $this->json($this->client, 'POST', '/api/register', [
|
||||||
|
'email' => 'regshort@test.dudi',
|
||||||
|
'password' => 'kurz',
|
||||||
|
'token' => $invite->getToken(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame(400, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/bootstrap.php
Normal file
13
tests/bootstrap.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|
||||||
|
if (method_exists(Dotenv::class, 'bootEnv')) {
|
||||||
|
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['APP_DEBUG']) {
|
||||||
|
umask(0000);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue