Skip to content

Projects Internals 9 Demo Testing and Traps

Ed Mozley edited this page Oct 9, 2026 · 3 revisions

Projects internals 9 - Demo data, testing and traps

The last page of the series: how the Projects demo data is built and imported (System β†’ Demo data β†’ Tasks and Projects), how to test the module without touching anybody's real data - including the AI project manager without an AI provider - the traps the module has already fallen into once, and what is deliberately not built yet.

Pages in this series: 1 Architecture Β· 2 Schema Β· 3 Services and API Β· 4 Plan, Timeline and dependencies Β· 5 Health and charts Β· 6 Governance Β· 7 Alerts, reports and AI Β· 8 Connections, templates, people Β· 9 Demo data, testing and traps Β· 10 Contractors


1. Demo data

Where it lives, and why there

The Projects demo is part of database/demo-data/tasks.json - the Tasks and Projects card on System β†’ Demo data - not a module of its own. Project tasks are rows in tasks, and the importer clears a module's previous demo rows table by table (DELETE FROM <table> WHERE is_demo = 1). A separate Projects module would therefore wipe the Tasks demo's tasks on every re-import, and the other way round.

Every project table carries is_demo TINYINT(1) NOT NULL DEFAULT 0 (#2240 added the last fifteen). The importer stamps is_demo = 1 on every row it inserts - set in code, never in the JSON, so a new demo file cannot forget it - and only those rows are removed on re-import. Real projects, and real rows in a demo project's child tables (a task somebody added to a demo project, history written by the app), are never deleted by the importer; rows that point at a demo project go with it through the foreign-key cascade.

What is in it (3.3.0)

Project Way of running / status What it exercises
Move to the Bradford office Staged, active, high The fullest example: four stages (one closed with a gate decision), 18 tasks with estimates, 11 dependencies incl. one deliberate clash, milestones, eight members (analysts, a team, People users) placed on the stakeholder map, scope + RACI, RAID with an escalated issue and a follow-up action, tolerances incl. cost, dated budget lines with a forecast and monthly labour, a project hourly rate, three benefits, gate checklists incl. a go-live gate, two change requests (one approved, one waiting) and two baselines, an approved and sent highlight report, a draft checkpoint, a monthly report schedule, optional links to a change, ticket, contract and article
Windows 11 laptop refresh Agile, active, critical Four sprints, estimates (burndown by hours), dependencies, an asset target with a filter rule (demo assets differ per install), a benefit measured twice, links to six demo laptops
Firewall replacement Simple, proposed A proposal waiting for approval (approval_status = pending, estimated cost and benefit, proposed by name and email as if from a form)
Mail migration to the cloud Staged, active, red Overdue and blocked work, a missed milestone, an escalated issue, a late dependency, a breached time tolerance, an approved CR that moved the plan, a waiting CR and a rejected one, an AI-drafted, edited and approved exception report, a stakeholder who is a blocker
Backup platform replacement Staged, closed A closure report (sent), three benefits with measures - two achieved, one on the way - lessons
Service desk improvement Agile, active Benefits reviewed monthly, one review due now
Restructure - HR system access changes Simple, active, members-only visibility = members
Phone system replacement Staged, on hold Health set red by hand with a note, a rejected change request
Intranet refresh Simple, cancelled A decision and a lesson

Plus 40 days of status history (project_task_flow) for every live project, so the cumulative flow diagram and the "statuses recorded from here" marker have something to show, and history (project_audit) rows for the creation, gates, escalations, baselines and changes.

The generator: scripts/gen_projects_demo.php

The projects part of tasks.json is generated, not hand-written - 875 rows that must agree with each other (a task's status with its dates, the flow history with the tasks, dependencies with start dates). Edit the projects in the generator, run it, and commit the JSON it writes:

php scripts/gen_projects_demo.php
# clash: po_phones waits for po_wifi        <- the one deliberate clash
# written: 9 projects, 72 project tasks, 875 rows in all

It reads tasks.json, keeps every non-project record (the Tasks demo's own tasks and comments) exactly as they are, removes every project record, and writes the projects again. Days are offsets from the day of import:

// [ref, title, stage, start, due, done|null, assignee, estimate, status-override?, priority?]
['po_cabling', 'Cabling installed and tested on every floor', 'ps_office_network', -15, -4, -3, 'lbrown', 24],
['po_internet', 'Internet line live', 'ps_office_network', -10, +4, null, 'lbrown', 4, 'Blocked', 'High'],

A task's status follows from its days (done in the past β†’ Done, started β†’ In Progress, else To Do) unless forced. The flow history is worked out from the same days, day by day, so the chart agrees with the plan. Before writing, the generator checks every dependency the way projectDependencyAnalysis() does (a task must start after what it waits for is due, plus the lag) and prints each clash - so the Timeline does not fill with red arrows by accident.

Builders keep the definitions short - $project(), $stage(), $task(), $dep(), $milestone(), $member(), $raid(), $item(), $raci(), $tol(), $line(), $benefit(), $gateItem(), $cr(), $baseline(), $report(), $link(), $audit(). The CLI guard (if (PHP_SAPI !== 'cli')) keeps it from running over the web.

The importer features it relies on (api/system/import_demo_data.php)

The generic importer was extended for Projects in 3.3.0 (#2240):

_optional records. A demo project links to other modules' demo records - a change, a ticket, six laptops. Those are found by _skip_insert records in tier 1 (_match_by a title or hostname; nothing is inserted). If the Changes demo is not imported, the reference does not resolve - and a plain record would fail the whole import. A record marked "_optional": true is simply left out instead:

if (!empty($record['_optional'])) {
    try { $record = resolveReferences($record, $idMap); }
    catch (Exception $e) { continue; }
    unset($record['_optional']);
}

So the projects import on their own, and gain their links when Assets, Changes, Tickets, Contracts, Knowledge and CMDB are imported first (the demo card says so). Members who are People users (@users.u_alice) are optional too - and so are the RACI cells that point at them. Re-importing Changes afterwards is safe: project_changes cascades.

Status names on the flow history. project_task_flow rows say "status": "In Progress"; the lookup table translates names to status_id exactly as for tasks.

demoAfterImport() (includes/demo_data.php). A baseline's snapshot holds stage and milestone ids, which do not exist until inserted. The JSON gives each baseline a recipe instead:

{"project_id": "@projects.prj_mail", "number": 1, "label": "Plan agreed",
 "snapshot": "{\"_demo\":{\"target_shift\":-14,\"budget_delta\":-2400,\"stage_shift\":-14}}"}

After the inserts, inside the import's transaction, demoAfterImport($conn, 'tasks') takes projectPlanSnapshot() of the project as imported and moves it by the recipe - the plan as it stood before the change request that moved it - so Change control shows real drift.

Testing an import without touching real data

The import replaces the previous demo projects - on a live install that includes anything somebody did to them. Test it against a scratch database instead:

mysql -uroot -e "CREATE DATABASE freeitsm_demotest CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
mysql -uroot freeitsm_demotest < database/freeitsm.sql      # also proves the schema file loads cleanly

then a small CLI harness that defines DB_NAME as the scratch database before config.php loads, forges a session file, sets $_POST['module'] and includes api/system/import_demo_data.php from its own directory. Import core then tasks (links skipped), then assets, changes, tickets, contracts, knowledge, cmdb and tasks again (links attached), then tasks once more (a clean replace) and changes (the cascade). A second harness includes any api/projects/*.php GET endpoint the same way to read every demo project back through get.php - health, critical path, clashes, earned value, flow points, baselines. Refuse in the harness to run against the real database name.


2. Testing

There is still no permanent test for Projects under tests/. Every 3.3.0 feature was checked by end-to-end scripts through the real endpoints; the patterns are the ones to copy.

Through the endpoints, not the services, so module access, CSRF and the permission rules are exercised. A forged session file (c:/wamp64/tmp/sess_<id> on WAMP) carrying analyst_id, is_admin and a csrf_token, sent as the PHPSESSID cookie with an X-CSRF-Token header on every POST.

As a non-admin as well. Admins short-circuit every capability (analystHasCapability()), so a test run only as one proves nothing about permissions. Pair each refusal with a positive control - the same write that should succeed - so a refusal for the wrong reason cannot pass. Members-only visibility was tested with an analyst who has Projects but does not manage it: not in the list, not found by id, not in search, Documents, Warbot, Report Packs or the calendar, the work still counted in Capacity under a neutral name - then made a member, then removed again.

A temporary project that deletes itself. Name it ZZ ..., create it in the test, delete it in a finally. Remember:

  • deleteProject() detaches tasks, it does not delete them - delete every task the test or a template created, by id (TasksService::deleteTask()), and the "task assigned" bells they raised.
  • Time-based alerts write ledger rows (workflow_scheduled_emissions) keyed on the entity - delete the test's keys (project_overdue:<pid>, project_stall:%, project_report_schedule:<pid>, project_milestone:% for milestones that no longer exist).
  • Settings a test changes must go back as they were - including absent (delete the row), not "saved as the default".
  • The CLI caches settings: after changing one over HTTP, call projectSettings($conn, true) before reading it in the same script.
  • Scan only the test's own project (projectAlertsScan($conn, $pid), projectAlertsStalled($conn, $pid)) - a full scan sends real digests to real project managers. Opening the portfolio (list.php) can run the full scan too (the no-cron fallback).

Never send real email. ProjectReportsService::send() goes out through the install's real mailbox. Test everything up to the mailer - rights, an unapproved report, a bad address, the 50 limit, the recipients list, emailHtml() escaping - and check the send log (email_send_log, route project_report) stayed empty.

The AI without an AI provider. Set Projects β†’ Settings β†’ AI (or aiSettingsSave($conn, 'projects_ai', [...]) on a scratch database) to provider azure with azure_endpoint pointing at a local PHP script - the Azure client posts to {endpoint}/openai/deployments/{deployment}/chat/completions?api-version=..., so an endpoint ending in mock.php?p= lands on the script. The script logs the request body and answers in the OpenAI chat-completions shape:

echo json_encode(['id' => 'mock', 'model' => 'mock', 'choices' => [['index' => 0,
    'message' => ['role' => 'assistant', 'content' => "- one\n- two"], 'finish_reason' => 'stop']]]);

That tests the briefing (and its 10-minute reuse - a second ask makes no request), every report kind, the scheduled AI draft, the system prompt and <project_data> wrapping, the facts the model is given (assert on phrases: Dependency CLASHES, Gate checklist for ... (go-live gate), SCEPTIC, CR-2 WAITING FOR A DECISION), and an unreachable provider (point it at a missing script: a RuntimeException the endpoint turns into ai_error: unreachable). The Warbot / MCP project tools need no model at all - call projectAssistList(), projectAssistOverview(), projectAssistRaid(), projectAssistTasks(), projectAssistDates() with warbotProjectScope($conn, $analystId). Remove the mock afterwards.

The Ask AI assistant needs a mock that calls tools. Answer in the OpenAI shape with message.tool_calls (function.arguments a JSON string) when the user text asks for a set-up, and with plain text once the last message is role: tool - the loop sends the results back and expects an answer. A request with no tools is the memory fold. Let the mock echo what the prompt carried (WHERE THIS PROJECT IS: BLANK, the memory heading, may NOT change) so the test can assert on it. Include one proposal with a bad id: it must be refused before it reaches the card. Then test apply order, a second apply doing nothing, somebody else's apply refused, the shared mode and Restart - 32 checks in all, none of which needs a real provider.

Screens. Headless Chrome screenshots through an iframe harness page that forges the session server-side; inject *{transition:none!important;animation:none!important} first (transitions never finish headless). A programmatic scroll leaves black unpainted blocks - use a tall window instead. Check dark and a 390px phone width.

When writing a permanent test, also cover: a task surviving the deletion of its project and stage; a method switch relabelling open stages and leaving closed ones; a second A in a RACI row demoting the first; a go on a closed stage starting nothing; a dependency loop refused; a gate go blocked by an open checklist item under block and noted under warn.


3. Traps worth remembering

Trap What happened
A class that sets display beats [hidden] .prj-chip (display:inline-flex) still showed when hidden; then in 3.3.0 an approved report still showed Approve, the AI note and the Edit / Preview tabs (#2237), because the module's re-assertion listed containers one by one and missed the report dialog. assets/css/projects.css now covers .prj-layout [hidden], .prj-page [hidden], .modal [hidden]
$icon in projects/help.php includes/header.php loops its nav as [$href, $label, $icon] and overwrote it. The help page's helper is $prjHelpIcon
Naming tasks.project_id in a Tasks query breaks the whole board before Verification. Guard with projectsSchemaReady() and keep it a separate query
Naming a newer table or column in the portfolio query breaks the whole portfolio before Verification. Every 3.3.0 column has its own guard (projectPriorityColumn(), projectVisibilityColumn(), ...Ready()) - see page 2
One guard for many tables projectLinksReady() used to need all seven link tables, so adding the seventh (problems) hid every link on an install that had not run Verification. It now checks per kind (#2242)
A new reader of projects must apply members-only visibility (projectVisibleSql() / projectVisibleTo() in includes/projects/visibility.php) - or a confidential project leaks into it. Page 1 lists every caller
The alert row is not the project row projectAlertRows() selects a subset of columns; the AI facts read priority, health note and currency from it and silently got the defaults until #2242 added them. Check the SELECT before reading a new field from a decorated row
The bell never tells you about your own action a scan run inside somebody's request would hide a time-based alert from them. Time-based dispatches go through projectAlertsAsSystem()
The bell folds by type the same event type on the same project becomes one bell row (event_count goes up, the title is the newest) - a test counting titles sees one
Scheduled AI drafts as nobody facts built for analyst 0 drop every module-gated line (linked tickets, changes, disruption). Drafts are now built as the project manager (#2241)
A go recorded on a closed or planned stage closing-and-advancing on every go would start another stage while a later one is in progress; decideGate() hands over only when the decision actually closes the stage, and refuses a planned one
FKs that never got added an upgraded install may lack keys that freeitsm.sql declares - Verification only adds what $projectFks lists (four budget-line / report keys were missing until #2243). Every delete in the services still removes children by hand; project_labour_rates has no FK at all (scope + ref_id), so deleteProject() removes a project's own rate by hand
Settings keys in the manifest would let the generic settings writer store them unvalidated. Keep them out
NOT (done) for "still to do" an asset with no status makes the test NULL, and NOT NULL is NULL - the asset vanished from the list while the count still counted it. projectTargetAssets() uses NOT COALESCE((done), 0)
api/v1/dev/openapi_fix.php rewrites api/v1/lib/openapi_schemas.php and strips its comments - never run it in place; edit the schema by hand
A conversation for the model that does not alternate Anthropic rejects two user turns in a row and a conversation starting with the assistant. aiProviderChatTools() merges neighbouring turns from the same side and puts a placeholder user turn before a leading assistant one (the Ask AI greeting is stored without a user message)
A UNIQUE key with a NULL in it project_ai_threads (project_id, analyst_id) - MySQL lets any number of NULLs through, so a shared conversation (analyst_id NULL) must be looked up before it is inserted, never upserted
The AI making changes it must not. Ask AI's propose_* tools only record a proposal; the change happens when the person presses Apply, through the services, as them. A new action for the assistant needs a propose_ tool, a line in projectChatDescribe(), a rank and a case in projectChatApplyOne() - never a tool that writes
Earned value with lumpy budget lines one labour line dated at the end makes planned value jump; split recurring costs by month (the demo does)
Test scripts against a real install a test that seeds and then "cleans up" by field name can delete somebody's own history. Use a temporary project and delete it, or delete only rows the test can prove it wrote; test imports on a scratch database
A test that saves settings "The setting was absent last time" is not a precondition - the install's owner may have set it since. A test that writes a setting snapshots the value first and puts that back, and a test that would overwrite the AI provider settings (projects_ai_*) refuses to run while any exist. Deleting them is unrecoverable: the key is stored encrypted
projectSettings() caches per request a test that writes a Projects setting and then exercises code reading it in the same process must call projectSettings($conn, true) first, or the code sees the old value

4. Not built yet

Against docs/design/projects.md: no per-stage tolerances (the column exists); no deliverable tree in the UI (project_items.parent_id exists); no budget lines in templates or Report Packs; the REST API (REST API: Projects) does not cover members, RACI, tolerances, asset targets, templates, announcing disruption, milestones, RAID escalation and actions (read only), capacity, reports and briefings, change requests and baselines, proposal decisions, benefits, gate checklists or dependencies; no project board; no keyboard way to move a Timeline bar (the dialogs are the keyboard path); milestones have no per-milestone owner; capacity has no per-person hours or leave and estimates are not split between a task's collaborators; dependencies are finish-to-start only, within one project, and never move dates by themselves; a task does not show which RAID entry it is an action of; announced disruption cannot be edited from the project; no link from a Calendar entry back to its project; contractors have their own list of gaps (part 10); task changes do not trigger the per-project alert scan (cron or the portfolio picks them up); Connections has no Service Status kind; asset targets cannot be re-ordered; reports are not a Report Packs block; proposals have one approver at a time; a benefit's owner and a sign-off must be analysts; members-only projects do not hide their tasks from Tasks and do not let a named approver in; status history starts on the day 3.3.0 is installed; a change request cannot name the scope items it adds or drops; drift from the baseline does not affect health; project.health is empty on most time-based events, so a workflow condition on it does nothing there.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally