calorie-ai-android/web/tests/app.spec.js
Daniel fcef0d48db
Some checks failed
Android CI / build (push) Successful in 1m34s
Web CI / test (push) Successful in 58s
Android Release / release-apk (push) Failing after 22s
Add personalized plans with Postgres storage
2026-05-20 23:43:03 +02:00

286 lines
13 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
);
async function mockModels(page) {
await page.route('**/api/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
}),
});
});
await page.route('**/api/models/search', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
count: 2,
models: [
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
],
}),
});
});
await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
}),
});
});
}
async function mockPlans(page) {
let plans = [];
let selectedPlanId = '';
await page.route('**/api/plans', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
}
const plan = route.request().postDataJSON();
plans = [plan, ...plans];
selectedPlanId = plan.id;
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
});
await page.route('**/api/plans/select', async (route) => {
selectedPlanId = route.request().postDataJSON().id || '';
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
});
await page.route('**/api/plans/delete', async (route) => {
const id = route.request().postDataJSON().id;
plans = plans.filter(plan => plan.id !== id);
if (selectedPlanId === id) selectedPlanId = plans[0]?.id || '';
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
});
}
async function mockEntries(page) {
let entries = [];
let trash = [];
await page.route('**/api/entries', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
}
const entry = route.request().postDataJSON();
const idx = entries.findIndex(e => e.id === entry.id);
if (idx >= 0) entries[idx] = { ...entries[idx], ...entry };
else entries = [...entries, entry];
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
});
await page.route('**/api/entries/delete', async (route) => {
const { ids } = route.request().postDataJSON();
const idSet = new Set(ids);
const moving = entries.filter(e => idSet.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() }));
entries = entries.filter(e => !idSet.has(e.id));
trash = [...moving, ...trash];
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
});
await page.route('**/api/entries/restore', async (route) => {
const { id } = route.request().postDataJSON();
const found = trash.find(e => e.id === id);
if (found) {
const { trashedAt, ...restored } = found;
entries = [restored, ...entries];
trash = trash.filter(e => e.id !== id);
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
});
}
async function mockSettings(page) {
let settings = {};
await page.route('**/api/settings', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) });
}
settings = { ...settings, ...route.request().postDataJSON() };
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) });
});
}
async function login(page) {
await mockModels(page);
await mockPlans(page);
await mockEntries(page);
await mockSettings(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
await page.locator('#password').fill('test-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
}
test('requires authentication before loading the app', async ({ page, request }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
const response = await request.post('/api/chat', { data: {} });
expect(response.status()).toBe(401);
});
test.describe('authenticated app', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('uses direct page navigation and validates missing meal input', async ({ page }) => {
await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible();
await expect(page.locator('#menu-search')).toHaveCount(0);
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
await page.locator('nav').getByRole('button').filter({ hasText: 'Log meal' }).click();
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
});
test('generates and fine-tunes versioned plans without raw markdown', async ({ page }) => {
let planCalls = 0;
await page.route('**/api/chat', async (route) => {
planCalls += 1;
const content = planCalls === 1
? '**Targets**\n- Calories: 1,700-1,900 daily\n- Protein: 120 g daily\n---\nHabits\n- Walk after dinner'
: 'Targets\nCalories: 1,650-1,850 daily\nProtein: 125 g daily\nHabits\nWalk 7,000 steps daily';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ choices: [{ message: { content } }] }),
});
});
await page.locator('nav').getByRole('button').filter({ hasText: 'Plans' }).click();
await expect(page.getByRole('heading', { name: 'Plans' })).toBeVisible();
await page.getByLabel('Sex').selectOption('Male');
await page.getByLabel('Age').fill('36');
await page.getByLabel('Height (cm)').fill('178');
await page.getByLabel('Current weight (kg)').fill('91');
await page.getByLabel('Target weight (kg)').fill('82');
await page.getByLabel('Why this matters').selectOption('Improve my overall health');
await page.getByLabel('Biggest challenge').selectOption('Being too busy');
await page.getByLabel('Tracking style').selectOption('Photo log meals');
await expect(page.getByText('Calculated target')).toBeVisible();
await page.getByRole('button', { name: 'Generate new plan' }).click();
await expect(page.getByText('Plan generated.')).toBeVisible();
await expect(page.getByText('Targets')).toBeVisible();
await expect(page.getByText('**Targets**')).toHaveCount(0);
await expect(page.getByText('---')).toHaveCount(0);
await page.getByLabel('Fine-tune with meals, activities, or progress notes').fill('I walked 7,000 steps and felt hungry at night.');
await page.getByRole('button', { name: 'Update plan with AI' }).click();
await expect(page.getByText('Plan updated.')).toBeVisible();
await expect(page.getByText('Updated plan 2').first()).toBeVisible();
await expect(page.getByText('Weight-loss plan 1')).toBeVisible();
});
test('saves account email and requests verification from settings', async ({ page }) => {
await page.route('**/api/account', async (route) => {
const body = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, email: body.email, emailVerified: false }),
});
});
await page.route('**/api/account/send-verification', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, message: 'Verification email sent.' }),
});
});
await page.locator('nav').getByRole('button').filter({ hasText: 'Settings' }).click();
await page.getByLabel('Account email').fill('tester@example.com');
await page.getByRole('button', { name: 'Save email' }).click();
await expect(page.locator('#accountStatus')).toContainText('Account email saved.');
await expect(page.getByText('Email not verified')).toBeVisible();
await page.getByRole('button', { name: 'Send verification email' }).click();
await expect(page.locator('#accountStatus')).toContainText('Verification email sent.');
});
test('adds curated models in settings, backfills a meal, edits, trashes, and restores', async ({ page }) => {
let calls = 0;
await page.route('**/api/chat', async (route) => {
calls += 1;
const content = calls === 1
? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}'
: '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from image and portion note."}';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ choices: [{ message: { content } }] }),
});
});
await page.locator('nav').getByRole('button').filter({ hasText: 'Settings' }).click();
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
await page.getByRole('button', { name: '+' }).last().click();
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#mealDate').fill('2026-05-18');
await page.locator('#mealTime').fill('08:15');
await page.locator('#mealType').selectOption('Breakfast');
await page.locator('#description').fill('salmon rice bowl with broccoli and berries');
await page.locator('#measure').fill('one dinner bowl');
await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png });
await expect(page.locator('#preview')).toBeVisible();
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Saved.');
await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click();
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 · Breakfast');
await expect(page.getByRole('button', { name: 'Move selected to trash' })).toHaveCount(0);
await page.getByRole('checkbox').check();
await expect(page.getByRole('button', { name: 'Edit selected' })).toBeVisible();
await page.getByRole('button', { name: 'Edit selected' }).click();
await expect(page.getByRole('heading', { name: 'Edit meal' })).toBeVisible();
await page.getByLabel('Calories').fill('610');
await page.getByRole('button', { name: 'Save edits' }).click();
await page.locator('nav').getByRole('button').filter({ hasText: 'Analytics' }).click();
await expect(page.locator('#calorieChart')).toBeVisible();
await expect(page.locator('#produceChart')).toBeVisible();
await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click();
await page.getByRole('checkbox').check();
await page.getByRole('button', { name: 'Move selected to trash' }).click();
await expect(page.locator('#entries')).toContainText('No meals match the current filters.');
await page.locator('nav').getByRole('button').filter({ hasText: 'Trash' }).click();
await expect(page.locator('#trashEntries')).toContainText('Salmon rice bowl');
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.locator('#trashEntries')).toContainText('Trash is empty.');
});
});