Skip to main content

Coverage Tracker — Tests, Docs, Specs

Purpose: track 100%-coverage progress for the Ever Works platform across tests (unit/e2e), docs, and specs. Maintained by the hourly platform-tests-and-docs scheduled task so successive runs do not duplicate work and nothing is missed.

Source of truth ordering: this file > existing CLAUDE.md > AGENTS.md. When a task is shipped (PR merged), the corresponding row is moved to the "Done" section with the merged PR link.

How an hourly run uses this file

  1. Read this tracker first.
  2. Pick the next item from "Pending — High Priority", or fall back to the next "Pending — Medium / Low" item if all high-priority is in flight.
  3. Open a feature branch, ship in one PR, merge to develop (no waiting for review — the scheduled task is authorized to merge per the task spec).
  4. Update this file in the same PR (move the item to "Done", add the PR link, record any follow-ups discovered).

Inventory snapshot (2026-05-07, refreshed 2026-05-09)

  • Spec files (*.spec.ts): ~521 across apps/ + packages/ (was 519 earlier on 2026-05-10; +2 net spec files in the agent entities-types contract sweep — adds packages/agent/src/entities/__tests__/activity-log.types.spec.ts (45 tests pinning the 96-LOC contract module: every one of the 36 documented ActivityActionType literals enumerated by name with it.each — these strings are persisted in activity_log rows and matched via string equality across the codebase; total-count + uniqueness + lowercase-snake_case format invariants pinned to catch silent additions; every ActivityStatus literal pinned; CreateActivityLogDto + ActivityLogQueryOptions minimal-and- maximal literal shapes type-check) and packages/agent/src/entities/__tests__/types.spec.ts (36 tests pinning the 91-LOC contract module: SubscriptionPlanCode 3 codes in ascending tier order; WorkMemberRole 4 roles + uniqueness; ASSIGNABLE_MEMBER_ROLES excludes OWNER (creator-only role contract pinned via not.toContain); DomainEnvironment 3 envs; smoke-checks on the 4 re-exports from @ever-works/contracts/api; GenerateStatus + CommunityPrState minimal-and-maximal shapes - lastError: null explicit-clear acceptance + processedPrs.outcome restricted to 'applied'/'ignored'; ClassToObject<T> mapped- type acceptance; ProvidersDto empty-literal acceptance), closing the per-file zero-coverage gap on the two entities-level contract modules — see Done ledger; +1 net spec file in the prior agent ExecutablePipelineRunner direct-coverage sweep — adds packages/agent/src/pipeline/executable-pipeline.class.spec.ts (49 tests on the 333-LOC runtime wrapper that owns the PipelineState mutation flow consumed by the step-pipeline executor — every running/completed/failed/skipped transition flows through this class, so a regression here would silently corrupt the run-state observed by both the orchestrator AND the activity-log emitter that reads runner.getState(). Pins: PipelineRuntimeEvents literal wire format (pipeline:state-changed + pipeline:step-status-changed — wire-format break is deliberate); the STATE_CHANGED is declared but NEVER emitted gotcha (only STEP_STATUS_CHANGED fires today — pinned so a future "wire up state-change events" feature is a deliberate change, not an accident); constructor initialises every step in pending status with empty completedSteps/failedSteps arrays; duplicate step IDs collapse via Map.set semantics (the second definition wins — pinned so a future "throw on duplicate id" refactor breaks loudly); zero-step pipeline does not throw; EventEmitter is optional (verified across all five status-mutating methods — none throw without an emitter); getStepDefinition reads through pipeline.steps.find NOT the internal Map (so post- construction mutations to pipeline.steps are reflected here but not in getStepState — useful invariant for caller code); startExecution stamps state.startedAt from Date.now() AND preserves completedSteps/failedSteps on a second call (no idempotency reset — pinned); completeExecution clears isRunning + stamps completedAt AND preserves startedAt (audit trail); cancelExecution is the only method that flips isCancelled to true; updateStepStatus throws verbatim 'Step "<id>" not found in pipeline' for unknown ids; sets startedAt ONLY on running and completedAt on completed/failed/skipped (not on pending/running); updates currentStep to the running step AND clears it on any other transition that matches the CURRENT step (parallel-step safety: transitioning a different step does NOT clear currentStep of an unrelated running step); STEP_STATUS_CHANGED payload shape pinned (stepId, previousStatus reflects pre-update state, newStatus, timestamp from Date.now()); previousStatus is HARDCODED in markStepComplete (always 'running') and markStepFailed (always 'running') and markStepSkipped (always 'pending') regardless of actual prior state — pinned so a future "compute previousStatus from state" refactor must update both source and tests deliberately; markStepComplete attaches result.metrics only when metrics are provided (omits result entirely when undefined, does NOT set {metrics: undefined}); preserves chronological append order across multiple completions (['s2','s1','s3'] if completed in that order — no internal sort); markStepFailed attaches the Error instance verbatim under stepState.error AND does NOT add the failed step to completedSteps; preserves multiple-failures append order; markStepSkipped is the only asymmetric mutator: it appends to completedSteps (skipped is "done") AND does NOT clear currentStep, so a step can be skipped while another step is running; full-lifecycle integration test confirms a 3-step pipeline emits exactly 6 STEP_STATUS_CHANGED events in s1:running → s1:completed → s2:running → s2:completed → s3:running → s3:completed order; mixed lifecycle (completed + failed + skipped) and cancel- mid-flight scenarios both exercised), closing the per-file zero-coverage gap on the runtime-state-machine in packages/agent/src/pipeline/executable-pipeline.class.ts — see Done ledger; +1 net spec file in the prior agent pipeline-result.validator direct-coverage sweep — adds packages/agent/src/pipeline/validators/pipeline-result.validator.spec.ts (78 tests on the 122-LOC pure-function validator pinning the validatePipelineResult(unknown) → {valid, errors[], result?} envelope used by the in-process pipeline executors to reject malformed PipelineResult shapes returned from third-party plugins: the early-return short-circuit on non-object inputs (null/undefined/ number/string/boolean) yielding the single-message ['Result must be an object'] envelope vs. the per-field accumulator branch that runs for arrays-as-objects (typeof [] === 'object', so arrays fall through to per-field checks instead of short-circuiting — pinned because a future Array.isArray short-circuit refactor would silently change behaviour); the eight per-field check ORDER pinned (success → outputs → stepsCompleted → totalSteps → state → duration → error → failedStep) so a reorder of the validator breaks loudly; the outputs object's five required arrays (items/categories/tags/ collections/brands) AND the documented short-circuit where the nested array checks DO NOT run when outputs itself is missing/null/non-object (single-envelope-error policy, no error-spam); the state object's partial-optional shape — state===undefined skips ALL nested checks, state===null/non-object produces a SINGLE envelope error and skips the four nested checks, but state===object runs all four nested checks (isRunning bool / isCancelled bool / completedSteps array / failedSteps array); the documented gotcha that duration is labelled "Optional fields validation" in the source comment block but is ACTUALLY required (no r.duration !== undefined gate before the typeof check) — pinned by an explicit "duration is NOT optional" describe block that fails if a future "make duration truly optional" refactor lands without updating callers; the truly-optional error field accepting undefined, string, AND instanceof Error (incl. TypeError as Error subclass) but rejecting null/number/plain-object; the truly-optional failedStep field accepting undefined and string but rejecting null/number; the "no range check" semantics for duration (negative numbers pass — pinned because a future >= 0 guard would change behaviour); NaN-passes-typeof-number gotcha pinned for stepsCompleted (a future switch to Number.isFinite should be deliberate); result envelope shape (result.result === input on success, result.result === undefined on failure, no clone); plus the validatePipelineResultOrThrow(unknown, pluginId?) thin wrapper — reuses the validator and projects the errors[] into a single Error message via errors.join('; '), with the documented two-format string Invalid pipeline result${pluginId ? \ from plugin ''` : ''}: pinned for both no-plugin-id AND with-plugin-id cases, the empty-string-pluginId-treated-as-falsy case (nofrom plugin '...'segment), the whitespace-only-pluginId-treated-as-truthy case (no implicit.trim(), included verbatim — pinned so a future trim refactor breaks loudly), success-on-failed-run (success: falsewith anerrorfield is a perfectly valid PipelineResult SHAPE — the wrapper only throws when the shape is wrong, not when the run itself failed), and the per-call fresh-Error-instance contract); +1 net spec file in the prior agentPipelineFacadeServicedirect-coverage sweep — addspackages/agent/src/pipeline/pipeline-facade.service.spec.ts(30 tests on the 300-LOC facade-binding service that creates theStepExecutionContextconsumed by both pipeline executors: guard-rails (throws on missingwork.user/ emptyuser.id); logger prefix [work.slug]applied to every log/debug/warn/error/ verbose call w/verbose?.()optional-chain tolerating undefined; bound AI facade w/aiModelOverridedefaulting intooptions.routing.modelOverrideandoptions.modelvia??- precedence (caller-supplied override wins); bound search / screenshot / content-extractor facades each forwarding their providerOverrides.into the underlying call; bound prompt facade carrying NO providerOverride field (workId+userId only); bound data-source facade w/ caller workId/userId OVERWRITTEN by binding (spread runs first),getEnabledSources ||-fallback on falsy userId; optional data-source dep undefined when not provided; binding context shape — non-AI facades do NOT carry aiModelOverride), closing the per-file zero-coverage gap on the third pipeline service in packages/agent/src/pipeline/after the orchestrator + full executor — seeDoneledger; +1 net spec file in the prior agentFullPipelineExecutorServicedirect-coverage sweep — addspackages/agent/src/pipeline/full-pipeline-executor.service.spec.ts(22 tests on the 239-LOC self-managed-pipeline executor:executeSTARTED→plugin.execute→COMPLETED w/ wrapper-measured duration override; full signal/options/onProgress forwarding; log-interceptor lifecycle (attached ononLogEntry, routed into the documented envelope, removed in finally on both success AND throw); invalid- result handling (non-object single error, multi-error '; 'join, FAILED emitted exactly ONCE); plugin.execute rejection →buildErrorPipelineResultenvelope w/totalStepsfromplugin.getStepDefinitions().length; executeWithCancellationabort wiring + listener removal in finally on success AND throw + cancel-rejection swallowed + skip-when-plugin-lacks-cancel;getPluginStatenull-safe wrapper; private emitter helpers envelope shapes), closing the per-file zero-coverage gap on the second of three pipeline executors — seeDoneledger; +2 net spec files in the prior agentPipelineOrchestratorService+comparison/typesdirect-coverage sweep — addspackages/agent/src/comparison-generator/comparison/types.spec.ts(11 tests on the 60-LOC contracts module pinningDEFAULTCOMPARISON_SETTINGSfour-default merge target +ComparisonProgressStageliteral union + minimal-and-maximal type literals for every documentedComparisonenvelope) andpackages/agent/src/pipeline/pipeline-orchestrator.service.spec.ts(33 tests on the 262-LOC routing service:executestep-vs-full routing viaisStepOrchestratablePipeline; executeWithModeforced step / forced full / full→step fallback w/ warn when no self-managed plugin exists;getRecommendedModefour-branch projection;hasFullPipelinePluginboolean;getAvailablePipelinePlugins registry filter chain (PIPELINEcapability + state===loaded +isPipelinePluginshape filter);resumeFromCheckpointproxy forwarding incl. null-passthrough;clearCheckpointproxy;resumeOrExecuteresume-then-fallback w/ self-managed plugins skipping resume entirely;resolvePipelinePluginprivate 3-level priority chain (explicit id w/registry.get+ state + scope check →defaultForCapabilitiespass → first-loaded-and-enabled fallback → throws when none available), incl. null-pipelineId vs string-pipelineId branch, wrong-capabilities skip viaisPipelinePlugin, work.userundefined → undefined userId forwarding toisPluginEnabledForScope. Total agent-package suite: 3474 → 3507 tests across 165 → 166 suites — see Doneledger; +3 net spec files in the prior security + utility coverage sweep #7 — addspackages/agent/src/utils/tests/work-changelog.utils.spec.ts(23 tests onbuildWorkChangelogcovering the empty-input null contract, count partitioning, entries reference passthrough, summary-override semantics including the empty-string preservation vs??-fallback gotcha, default-summary builder pluralisation + fixed , join order + zero-count omission, entityType label resolution across all 5 documented literals (item/comparison/ category/tag/collection) with entries[0]-only derivation pinned, ?? 'item'fallback for malformed entries, and the exact 5-field envelope shape with no extra keys),packages/agent/src/utils/tests/github-app.utils.spec.ts(30 tests on the security-critical 105-LOC GitHub-App utility — RS256 JWT generation w/ public-key round-trip verification +iat = now-60/exp = now+9*60/iss = appIdpayload pinning + base64url segment shape + appId verbatim-passthrough;Bearerheader construction;requestGitHubAppInstallationAccessToken*fetch URL/method/headers shape +expires_at ?? nullcoercion + non-OK error message format + missing/empty-token rejection;verifyGitHubWebhookSignatureconstant-time compare with the explicit length-guard pinned ahead oftimingSafeEqualto avoid the RangeError it throws on mismatched-length inputs, plus scheme-prefix strict-equality check, secret-dependence check, body-dependence check, and empty-body happy-path), andpackages/agent/src/pipeline/tests/pipeline-result.validator.spec.ts(53 tests onvalidatePipelineResult+validatePipelineResultOrThrow— the type-checker every pipeline plugin output passes through — covering the non-object short-circuit, happy-path reference passthrough, all top-level scalar typeof checks, theoutputs-null guard that prevents child-error cascading, all 5 output-array fields independently rejected when missing/non-array, state(optional) three-way branch (missing → pass / explicitly-null → error + skip children / non-object → error),erroraccepting string ORErrorinstance,failedSteptypeof-string only, declaration-order error accumulation, and the throwing variant'spluginIdinterpolation including the empty-string-as-no-plugin behaviour pinning thepluginId ? ...falsy branch). Earlier on 2026-05-09 was 507; +3 net spec files in the agent tiny-utility coverage sweep — addspackages/agent/src/activity-log/activity-log-analytics-dispatcher.spec.ts(7 tests on the string DI-token literal +ActivityLogAnalyticsDispatcherinterface + barrel re-export — pins the string-vs-Symbol-token shape so a future swap toSymbol(...)(which would silently break every cross-module@Inject(ACTIVITY_LOG_ANALYTICS_DISPATCHER)binding) is a deliberate change),packages/agent/src/comparison-generator/comparison/prompt-keys.spec.ts(8 tests on the three documented Langfuse-facing prompt keyscomparison.structure/comparison.markdown/comparison.extended-analysis - dotted-namespace regex + uniqueness +as const`JSON-roundtrip
    • barrelCOMPARISON_PROMPT_KEYS-rename pin — pinned so the agent-package keys stay aligned with the Langfuse UI), and packages/agent/src/plugins/utils/**tests**/plugin-model-settings.utils.spec.ts (22 tests onbuildProviderModelSummariescovering all 5 documented rules: schema-without-model-fields short-circuit (undef schema / no properties / nox-widget:'model-select'/ all-empty-resolved → undefined), happy-path summary shape (key/label/value/source/isWorkOverride), isWorkOverride === source==='work'matrix across all 5 SettingSource literals,title || keyfallback (incl. empty-stringtitlefalsy gotcha), value normalisation (whitespace trim, post-trim empty skip, non-string number/null/undefined skip, missing-from-map skip, undefined-resolved-arg, setting.sourceundefined coercion), dedup by trimmed value (collapses same-model-twice, case-sensitive distinction, leading/trailing whitespace does NOT bypass dedup),MODEL_FIELD_ORDER sort (defaultModelsimpleModelmediumModelcomplexModelmodel with reverse-registered properties as the proof, unrecognised keys pushed to end, both-unrecognised → stable insertion order viareturn 0), and non-model-field filtering ignoring x-widget:'password'/no-widget siblings). Closes three previously-uncovered files in packages/agent/src/ — seeDoneledger; +1 net spec file in the prior agentdatabase.config direct-coverage sweep — adds packages/agent/src/database/database.config.spec.ts(28 tests on the 201-LOC config registrar covering:ENTITIESlist shape invariants (>20 entities, all functions, no duplicates); SQLite branch matrix (test env →:memory:; CLI app type → ~/.ever-works/ever-works.db; API+development w/ in-memory=false → tmpdir/ever-works-api.db; API+development w/ in-memory=true → :memory:; falsy getEnvironment→ development fallback; falsy getAppType→ API fallback; sqlite/sqlite3 alias coercion to better-sqlite3;:-prefix path skips mkdir; mkdir-on-missing-dir vs no-mkdir-on-existing); SSL mode (sslMode=true → getTlsOptions call; sslMode=false →sslfield omitted; autoMigrate→synchronize; loggingEnabled passthrough); DATABASE_URL branch (parser invoked, url+database both forwarded; null parser → undefined database; honoured even for mysql); Postgres host defaults (localhost / 5432 / postgres / "" / ever_works) + every override; parseInt port coercion; MySQL/MariaDB alias normalisation tomysqldriver + 3306 / root / ever_works defaults + overrides; unknown-type fallback to better-sqlite3 :memory:; getDatabaseConfigwrapper proxy. Mocks the entity barrels at module scope to empty class shells so the TypeORM CJS init never loads — sidesteps the known path-scurryinitialization bug under Jest), closing the per-file zero-coverage gap on the onlydatabase/*.tsfile outside the repository sub-tree that lacked direct unit coverage — seeDone ledger; +1 net spec file in the prior agentsanitize.util direct-coverage sweep — adds packages/agent/src/utils/**tests**/sanitize.util.spec.ts(54 tests on the security-critical 213-LOC sanitization utility —sanitizeText default-options matrix + every override toggle independently pinned + themaxLength:0falsy-zero gotcha + documented operation order control-strip → newline-replace → collapse → trim → maxLength; sanitizeDescription500-char cap;sanitizeName100-char cap; sanitizePrompt5000-char cap w/ newlines AND multi-space runs preserved;sanitizeObjectrecursive walk w/ non-mutation guarantee
      • array-element recursion + null-guard;sanitizeStringTransform andsanitizeDescriptionTransformclass-transformer adapters; sanitizeStringArrayfalsy/non-array →[]+ per-entry sanitise + post-filter empty-string drop), closing the security-critical zero-coverage gap on the most-imported utility in the agent package — seeDoneledger; +3 net spec files in the prior agent database-helpers + enrichment-prompt utility sweep — adds packages/agent/src/database/utils/helper.spec.ts(16 tests on getTlsOptions+parseDatabaseUrl), packages/agent/src/database/database-config.factory.spec.ts (27 tests on the 7 documentedDatabaseConfigurationsentries + createDatabaseModuleWithEnvenv-write contract + cross-config invariant), andpackages/agent/src/import/enrichment-prompt.utils.spec.ts (21 tests onbuildImportGenerationDtocovering name fallback, hardcoded generation_method/website_repository_creation_method, pluginConfig defaults, providers default-agent-pipeline merging, and the prompt's expansion-factor matrix + four-step header ordering + legal-safety + 30%-cap directives). Closes three previously-uncovered tiny utility files inpackages/agent/src/covering 279 LOC of source. Total agent-package suite: 3185 → 3249 tests across 154 → 157 suites — seeDoneledger; +10 net spec files in the prior agent NestJS-module wiring sweep — adds\_.module.spec.tsfor ALL 10 previously-uncovered modules inpackages/agent/src/: work-operations, notifications, activity-log, community-pr, template-catalog, comparison-generator, pipeline, import, generators/website-generator, generators/data-generator. 50 tests across the 10 specs pinning the standard Reflect.getMetadata-based provider/exports/imports shape (with documented per-module length invariants so a silent extra-import is a deliberate change), plus barrel re-exports where present. Each spec mocks transitive ESM-only / TypeORM-pulling dependencies (DatabaseModule, FacadesModule, data-generator.servicew/p-map, etc.) at module scope — same pattern as the existing markdown-generator.module.spec.ts /account-transfer.module.spec.ts/subscriptions.module.spec.ts. This closes the agent-package NestJS-module zero-coverage gap entirely (every \*.module.tsunderpackages/agent/src/now has a co-located spec). Highlights:WorkOperationsModule's deliberate DatabaseModulere-export pinned;CommunityPrModule's DistributedTaskLockServiceprovided locally but NOT exported pinned; DataGeneratorModule's WorksConfigService/WorksConfigWriterService helpers provided but NOT exported pinned;PipelineModule's intentional non-import of PluginsModule(which isforRoot-global) pinned; — see Doneledger; +2 net spec files in the prior agentMarkdownGeneratorServicedirect-coverage sweep — adds packages/agent/src/generators/markdown-generator/markdown-generator.service.spec.ts (49 tests on the 508-LOC orchestrator) and packages/agent/src/generators/markdown-generator/markdown-generator.module.spec.ts (5 tests pinning the module providers/exports + barrel runtime symbols), closing the per-file zero-coverage gap inside packages/agent/src/generators/markdown-generator/for the orchestrator surface — seeDoneledger; +1 net spec file in the prior agent WebsiteUpdateServicedirect-coverage sweep — adds packages/agent/src/generators/website-generator/website-update.service.spec.ts (26 tests coveringupdateRepositoryduplicate-then-template fallback chain w/ wrapped "All update methods failed" rethrow, options.branchoverride +getLatestCommit null-coercion + falsy-branchSynccoercion;ensureTemplateDefaultBranch best-effort warn-and-continue across listing-fail / branch-missing / non-Error rejection /updateRepository-fail; thin syncAllBranchesFromTemplatedelegate;checkForUpdate four-branch projection w/ beta-branch path; privateupdateFork pinned via reflection;copyRepositoryFiles .git-skip + recursive subdir mirror w/ rm-rejection swallowed), closing the per-file zero-coverage gap inside packages/agent/src/generators/website-generator/for the update surface — seeDoneledger; +2 net spec files in the prior agent markdown-generator helper-class sweep — adds packages/agent/src/generators/markdown-generator/readme-builder.spec.ts (17 tests on the fluentReadmeBuilderbuilder —addHeader/addSubHeader/addParagraph/addNewLinechaining, enableToC()opt-in rendering w/## 📑 Table of Contents - en-US-formatted counts + duplicate-slug-1/-2disambiguation via github-slugger +0rendered as(0)not omitted,addItem format- [name](url) - description+ optional([Read more](/details/<slug>.md))+ backtick-wrapped tag list joined w/ spaces, end-to-end document shape) and packages/agent/src/generators/markdown-generator/markdown-repository.spec.ts (13 tests on the on-disk repository helper —cleanuprecursive rm, resetFiles allowlist (.git, .gitignore, .github, .vscode, .env, .nvmrc, plus every dotfile starting with .git) + sequential await-per-iteration removal order + readdir-failure short-circuit, ensureWorksExistrecursive mkdir ofdetails/, writeReadme/writeDetails/writeLicense utf-8 writes,removeDetailsrm withforce:trueonly — NOT recursive, the public readonlydirfield, and thedirdir/detailsderivation contract —node:fs/promisesis mocked at module scope so the suite never touches the real filesystem), closing the markdown-generator helper-class zero-coverage gap (the remaining file in the subdir ismarkdown-generator.service.ts, 508 LOC, deferred to a dedicated follow-up) — see Doneledger; +1 net spec file in the prior agent BranchSyncServicedirect-coverage sweep — adds packages/agent/src/generators/website-generator/branch-sync.service.spec.ts (24 tests coveringsyncBranchclone-rename-replaceRemote-push-cleanup pipeline + temp-dir cleanup on failure paths,syncAllBranches branch-mapping expansion + skip-mapped-target rule + sequential MAX_CONCURRENT_SYNCS=1semantics + per-batch 1000ms inter-batch delay +Promise.allSettledrejection-to-error-result coercion + optionalcleanupExtraBranchespurge w/ swallowed delete failures - warn-and-return-early onlistBranchesfailure,syncFromTemplate beta-branch-mapping construction + outer try/catch null fallback + resolveForWork-rethrow pin), closing the per-file zero-coverage gap insidepackages/agent/src/generators/website-generator/for the branch-sync surface — seeDoneledger; +2 net spec files in the earlier agentBaseFacadeService/FacadesModuledirect-coverage sweep — adds packages/agent/src/facades/**tests**/base.facade.spec.ts(66 tests on the abstract base via aTestFacadeService extends BaseFacadeService re-exposer) andpackages/agent/src/facades/**tests**/facades.module.spec.ts (9 tests pinning the module providers/exports + barrel runtime symbols), closing the per-file zero-coverage gap insidepackages/agent/src/facades/ — seeDoneledger; +0 net spec files in the WorkGenerationService orchestrators follow-up sweep — extends the existing services/**tests**/work-generation.service.spec.ts from 126 → 172 tests, closing the previously-pending 7 multi-step pipeline orchestrators (processGeneration/executeGenerationPipeline/finalizeGeneration/ runInProcessGeneration/prepareProviders/ ensureProvidersEnabledForWork/dispatchGenerationTask) — see Done ledger; the WorkGenerationService private-orchestrator zero-coverage gap is now empty. The earlier 2026-05-09 sweep covered 7 simpler helpers:resolveGenerationFinalStatus/resolveGenerationErrorMessage/ buildScheduleRunOutcome/isNonFatalWebsiteGenerationError/ markGenerationStarted/finalizeCancelledGeneration/ handleErrorNotification; +1 packages/agent service spec landed 2026-05-09 in the small-service coverage sweep #6 — WorkGenerationService(focused first sweep covering wrapper / simpler methods) — seeDoneledger; +1 packages/agent service spec landed earlier 2026-05-09 in the small-service coverage sweep #5 —ItemHealthService— see Doneledger; +1 packages/agent service spec landed earlier 2026-05-09 in the small-service coverage sweep #4 —WorkTaxonomyService— see Doneledger; +1 packages/agent service spec landed earlier 2026-05-09 in the small-service coverage sweep #3 —WorkMemberService— see Doneledger; +3 packages/agent service specs landed earlier 2026-05-09 in the small-service coverage sweep #2 —ItemSourceValidationSchedulerService+ WorkDetailService+RepositoryManagementService— seeDoneledger; +2 packages/agent service specs landed earlier 2026-05-09 in the small-service coverage sweep —WorkAdvancedPromptsService+ WorkWebsiteRepositoryStateService— seeDoneledger; +1 prior packages/agent service spec landed 2026-05-09 in the WorkOwnershipService sweep — seeDoneledger; +4 packages/agent repository specs landed earlier 2026-05-09 in sweep #3: activity-log + notification + work-member + work — closing the agent-package repository zero-coverage gap entirely; +4 in the previous sweep #2: work-custom-domain + user + conversation + template; +8 in the prior sweep — subscription-plan + work-advanced-prompts + user-template-preference + user-subscription + usage-ledger + webhook-subscription + refresh-token + onboarding-request. SeeDone for the running ledger).
  • Playwright e2e suites: 31 in apps/web/e2e/
  • API source spec count: 101 specs inside apps/api/src/ (was 95 earlier on 2026-05-09; +6 small-utility DTO specs landed in the tests/api-misc-dtos-coverage sweep covering AddDomainDto + RemoteCallDto
    • RegisterWorkRequestDto + 7 template-catalog DTOs + 7 plugins update-settings DTOs + OpenAI-compat DTOs — 145 new tests).
  • Spec Kit features (docs/specs/features/): 33 directories (was 24 on 2026-05-07; +9 retrospective specs authored on 2026-05-08).
  • Plugins with ZERO unit tests (14): ALL closed as of 2026-05-07 — every previously-zero plugin (brave, brightdata, comparison-generator, exa, firecrawl, github, jina, linkup, local-content-extractor, perplexity, scrapfly, serpapi, tavily, valyu) now has a unit suite.
  • Internal packages with ZERO tests: ALL closed (contracts, monitoring, cli-shared, tasks all covered as of 2026-05-08).
  • Agent-package submodules with ZERO tests (2026-05-08): cache ✅, community-pr ✅, config ✅, constants ✅, dto ✅, events ✅, notifications ✅, onboarding ✅, subscriptions ✅, tasks ✅, account-transfer ✅, items-generator ✅ (twelve closed 2026-05-08). The agent-package zero-coverage gap list is now empty.

Done

Most-recent first. The 2026-05-08 row for the agent config + constants + onboarding submodules sits above the existing header so it is rendered as plain text rather than a misaligned table cell — the table that follows is unchanged.

2026-05-10 — packages/agent entities-types contract coverage (+81 tests across 2 new specs, scheduled-task platform-tests-and-docs cycle, [PR pending])

Closes the per-file zero-coverage gap on the two entities-level contract modules packages/agent/src/entities/activity-log.types.ts (96 LOC) and packages/agent/src/entities/types.ts (91 LOC). Every literal pinned by these specs is a documented runtime contract — persisted in DB rows, matched via string equality across listener filter chains, DB queries, API DTOs, and consumer plugin code. A silent rename to ANY of these literals is a backwards-incompat break for every persisted row.

packages/agent/src/entities/__tests__/activity-log.types.spec.ts (45 tests)

  • ActivityActionType (39 tests) — every one of the 36 documented literals enumerated by name with a per-literal it.each assertion ('GENERATION' → 'generation', 'COMPARISON_GENERATION' → 'comparison_generation', ... 'COMMUNITY_PR_MERGED' → 'community_pr_merged'). Plus three structural invariants pinned: total-count of 36 literals (catches silent additions); uniqueness across the literal set; format regex /^[a-z][a-z0-9_]*$/ (lowercase snake_case, no UPPER, no kebab) — pinned because the listener side of the activity-log pipeline lowercases incoming strings before matching, so a future "let's use kebab-case for plugin actions" addition would silently route to the default branch.
  • ActivityStatus (7 tests) — every documented literal (PENDING/IN_PROGRESS/COMPLETED/FAILED/CANCELLED); total-count of 5 + uniqueness invariants.
  • CreateActivityLogDto (3 tests) — minimal shape with the 5 required fields; maximal shape with every documented optional field (workId/details/metadata/ipAddress/userAgent); runtime check that actionType is a valid enum member.
  • ActivityLogQueryOptions (2 tests) — minimal {userId} shape; maximal shape with every optional filter (actionType/workId/status/dateFrom/dateTo/search/limit/offset).

packages/agent/src/entities/__tests__/types.spec.ts (36 tests)

  • SubscriptionPlanCode (4 tests) — pinned 3 codes (FREE/STANDARD/PREMIUM); total-count + ascending tier order pinned via toEqual(['free', 'standard', 'premium']) so the ordering invariant is observable in test diffs.
  • WorkMemberRole (6 tests) — pinned 4 roles (OWNER/MANAGER/EDITOR/VIEWER); total-count + uniqueness invariants.
  • ASSIGNABLE_MEMBER_ROLES (4 tests) — contains MANAGER + EDITOR + VIEWER (in that order); explicitly EXCLUDES OWNER pinned via not.toContain (the creator-only role contract documented in the JSDoc — pinned because the role-assignment endpoints reject OWNER specifically); AssignableMemberRole type union derived from the array members.
  • DomainEnvironment (4 tests) — pinned 3 envs (PRODUCTION/STAGING/DEVELOPMENT); total-count.
  • @ever-works/contracts/api re-exports (4 tests) — defensive smoke-checks that GenerateStatusType/WorkScheduleCadence/WorkScheduleStatus/WorkScheduleBillingMode are objects with at least one string member (the contracts package owns the literal set; the agent's job here is just to make sure the re-export chain hasn't been accidentally severed).
  • GenerateStatus (2 tests) — minimal shape with only the required status field; maximal shape with every optional field (step/stepName/stepIndex/totalSteps/progress/itemsProcessed/error/warnings/recentLogs).
  • CommunityPrState (4 tests) — minimal shape (only processedPrNumbers required); maximal shape with every optional field; lastError: null explicit-clear acceptance pinned (the type allows string | null so the listener can clear the field without omitting it); processedPrs.outcome restricted to 'applied'/'ignored' literals (compile-time check via type-asserted variables).
  • ClassToObject<T> mapped-type (1 test) — verifies that an object-literal mirror of a class shape type-checks.
  • ProvidersDto re-export (1 test) — empty-literal {} accepted (every field is optional).

Total agent-package suite: 4040 → 4121 tests across 181 → 183 suites, all green.


2026-05-10 — packages/agent ExecutablePipelineRunner direct coverage (+49 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, [PR pending])

Closes the per-file zero-coverage gap on packages/agent/src/pipeline/executable-pipeline.class.ts (333 LOC) — the runtime wrapper that owns the PipelineState mutation flow consumed by the step-pipeline executor (step-pipeline-executor.service.ts:159 instantiates a fresh ExecutablePipelineRunner per pipeline run). Every running/completed/failed/skipped transition flows through this class via startStep/markStepComplete/markStepFailed/markStepSkipped, and every state read (getState(), getCurrentStep(), getStepState()) returns a snapshot built up here — so a regression in any of the five mutators would silently corrupt the run-state observed by both the orchestrator AND the activity-log emitter. The new file is packages/agent/src/pipeline/executable-pipeline.class.spec.ts and pins:

  • PipelineRuntimeEvents barrel (2 tests) — literal wire format pipeline:state-changed + pipeline:step-status-changed (changing these strings is a wire-format break and must be deliberate). PLUS the STATE_CHANGED-declared-but-never-emitted gotcha: today only STEP_STATUS_CHANGED fires; STATE_CHANGED is reserved/unused. Pinned so a future "wire up state-change events" feature is a deliberate change, not an accident.
  • Constructor / initial state (5 tests) — every step starts in pending status with empty completedSteps/failedSteps arrays and undefined currentStep/startedAt/completedAt; getPipeline() returns the original reference (no clone); zero-step pipeline does not throw; duplicate step IDs collapse via Map.set semantics (the second definition wins — pinned so a future "throw on duplicate id" refactor breaks loudly); EventEmitter is optional (constructor + every mutator works without one).
  • Read accessors (4 tests)getStepState returns the stored state by id (undefined for unknown ids); getStepDefinition reads through pipeline.steps.find NOT the internal Map (so post-construction mutations to pipeline.steps are reflected here but NOT in getStepState — useful invariant for caller code); getCurrentStep is undefined initially, set to the running step id, and cleared on completion; isRunning/isCancelled reflect state.isRunning/state.isCancelled accurately across the lifecycle.
  • startExecution (2 tests) — stamps state.startedAt from Date.now() AND sets isRunning=true; preserves completedSteps/failedSteps on a second call (no idempotency reset — pinned so a future "reset on start" refactor must update this test deliberately).
  • completeExecution (2 tests) — clears isRunning + stamps completedAt; preserves startedAt for the audit trail.
  • cancelExecution (1 test) — only method that flips isCancelled to true (also clears isRunning and stamps completedAt).
  • updateStepStatus (8 tests) — throws verbatim 'Step "<id>" not found in pipeline' for unknown ids; sets startedAt ONLY on running transition; sets completedAt on completed/failed/skipped (not on pending/running); updates currentStep to the new running step AND clears it on any other transition that matches the CURRENT step (parallel-step safety: transitioning a different step does NOT clear currentStep of an unrelated running step); STEP_STATUS_CHANGED payload shape pinned (stepId, previousStatus reflects pre-update state, newStatus, timestamp from Date.now()); previousStatus reflects the actual prior state (NOT the new status); does NOT emit when constructed without an EventEmitter.
  • startStep convenience (1 test) — pure delegation to updateStepStatus(stepId, 'running').
  • markStepComplete (7 tests) — throws verbatim error for unknown step; sets status=completed, stamps completedAt, appends to completedSteps, clears currentStep; attaches result.metrics ONLY when metrics provided (omits result entirely when undefined, does NOT set {metrics: undefined}); preserves chronological append order across multiple completions (['s2','s1','s3'] if completed in that order — no internal sort); previousStatus is HARDCODED to 'running' in the emitted event regardless of actual prior state — pinned so a future "compute previousStatus from state" refactor must update both source and tests deliberately; does NOT throw without an EventEmitter.
  • markStepFailed (6 tests) — throws verbatim error for unknown step; sets status=failed, stamps completedAt, appends to failedSteps, clears currentStep; attaches the Error instance verbatim under stepState.error AND does NOT add the failed step to completedSteps; preserves multiple-failures append order; previousStatus is HARDCODED to 'running'; does NOT throw without an EventEmitter.
  • markStepSkipped (6 tests) — throws verbatim error for unknown step; sets status=skipped, stamps completedAt, appends to completedSteps (skipped is "done"); omits result when reason undefined; the only asymmetric mutator — does NOT clear currentStep, so a step can be skipped while another step is running (a future refactor that "unifies" the three methods must be deliberate); previousStatus is HARDCODED to 'pending' (distinct from complete/failed which both use 'running'); does NOT throw without an EventEmitter.
  • Full lifecycle integration (3 tests) — a 3-step pipeline emits exactly 6 STEP_STATUS_CHANGED events in s1:running → s1:completed → s2:running → s2:completed → s3:running → s3:completed order; mixed lifecycle (completed + failed + skipped) preserves the right step lists; cancellation mid-flight preserves pre-cancel completedSteps AND stamps the cancellation timestamp.

Total agent-package suite: 3981 → 4030 tests across 179 → 180 suites, all green. Closes the per-file zero-coverage gap on packages/agent/src/pipeline/executable-pipeline.class.ts — the remaining pipeline gap is pipeline/step-pipeline-executor.service.ts (813 LOC), the richest integration-style target tracked separately.

2026-05-10 — packages/agent pipeline-result.validator direct coverage (+78 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #682)

Closes the per-file zero-coverage gap on packages/agent/src/pipeline/validators/pipeline-result.validator.ts (122 LOC) — the pure-function shape validator used by the in-process pipeline executors to reject malformed PipelineResult envelopes returned from third-party pipeline plugins (e.g. standard-pipeline, agent-pipeline, the CLI-wrapper plugins like claude-code / codex / gemini / opencode). A regression here would let an ill-formed plugin response propagate downstream and crash the orchestrator at a much later (and harder-to-diagnose) call site. The new file is packages/agent/src/pipeline/validators/pipeline-result.validator.spec.ts and pins:

  • Non-object short-circuit (5+1 tests)null, undefined, number, string, boolean all return the single-error envelope {valid:false, errors:['Result must be an object'], result:undefined}. Plus the arrays-fall-through gotcha: typeof [] === 'object' && [] !== null, so validatePipelineResult([]) does NOT short-circuit on the early return — it falls through to the per-field accumulator and produces a list of field errors instead of the single envelope error. Pinned so a future Array.isArray short-circuit refactor breaks loudly.
  • Happy path (4 tests) — fully-populated valid result, in-flight result (state.isRunning=true), cancelled result (state.isCancelled=true), and failed-but-shape-valid result (success: false).
  • success field (5 tests, parametrized)undefined, null, number, string, object all rejected with verbatim 'Missing or invalid "success" field (expected boolean)'.
  • outputs field (16 tests)outputs===missing/null/non-object produces a SINGLE envelope error and the nested array checks DO NOT run (no error spam — pinned because the implementation has explicit short-circuit logic). The five required nested arrays (items, categories, tags, collections, brands) each rejected when missing OR when set to a non-array (object instead of array). When outputs={}, ALL FIVE nested errors are reported. Forward-compatibility: extra unknown keys on outputs are accepted (does not over-validate).
  • stepsCompleted / totalSteps fields (7 tests) — both rejected when undefined/null/string. PLUS the NaN-passes-typeof-number gotcha pinned for stepsCompleted (since typeof NaN === 'number' the typeof check passes — a future switch to Number.isFinite should be deliberate).
  • state field (12 tests) — three-mode optionality: state===undefined skips ALL nested checks (zero state errors); state===null/non-object/string produces a SINGLE envelope error and skips the four nested checks; state===object runs all four nested checks (isRunning bool / isCancelled bool / completedSteps array / failedSteps array). When state={}, ALL FOUR nested errors are reported.
  • duration field (6 tests) — pinned the documented gotcha that duration is labelled "Optional fields validation" in the source comment block but is ACTUALLY required (no r.duration !== undefined gate before the typeof check). Rejected when missing / undefined / null / string. Accepts 0 AND negative numbers (no range check — pinned because a future >= 0 guard would change behaviour).
  • error field (7 tests, truly optional) — accepts undefined, string, instanceof Error, AND instanceof TypeError (Error subclass — instanceof Error matches). Rejects number, plain object, AND null (null is not undefined, not a string, and not instanceof Error).
  • failedStep field (4 tests, truly optional) — accepts undefined and string; rejects number and null.
  • Result envelope shape (3 tests)result.result === input (same reference, no clone) on success; result.result === undefined on failure; the eight per-field check ORDER pinned (success → outputs → stepsCompleted → totalSteps → state → duration → error → failedStep) so a reorder of the validator breaks loudly.
  • validatePipelineResultOrThrow thin wrapper (8 tests) — returns the validated result unchanged on success (same reference); throws Error with verbatim Invalid pipeline result: <errors-joined-with-"; "> when no plugin id; throws Error with Invalid pipeline result from plugin '<id>': <joined> when plugin id provided. Pinned: empty-string pluginId treated as falsy (no from plugin '...' segment); whitespace-only pluginId treated as truthy and included verbatim with NO implicit .trim() (a future trim refactor breaks loudly); success-on-failed-run is valid (a success: false with an error field is a perfectly valid PipelineResult SHAPE — the wrapper only throws when the SHAPE is wrong, not when the run itself failed); per-call fresh Error instance (not a singleton).

Total agent-package suite: 3903 → 3981 tests across 178 → 179 suites, all green. Closes the per-file zero-coverage gap on packages/agent/src/pipeline/validators/ (the only file in that directory) — the remaining pipeline gap is now pipeline/executable-pipeline.class.ts (333 LOC) and pipeline/step-pipeline-executor.service.ts (813 LOC), both of which are richer integration-style targets and tracked separately.

2026-05-09 — packages/agent WorkPluginRepository direct coverage (+38 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, [PR pending])

Closes the per-file zero-coverage gap on packages/agent/src/plugins/repositories/work-plugin.repository.ts (272 LOC) — the TypeORM-wrapper repository for work_plugins rows (the per-work analogue of user_plugins, holding per-work plugin enable state + scoped settings + active-capability assignments). The repository owns the single-active-plugin-per-capability invariant: a work can have many plugins enabled but only one active for each capability (e.g. one search provider out of tavily / brave / linkup ...). The new file is packages/agent/src/plugins/repositories/work-plugin.repository.spec.ts and pins:

  • create / findByWorkAndPlugin / findById (4 tests)create+save proxy; composite-key where: { workId, pluginId } + pluginEntity relation; null passthrough for both.
  • findByWork mixed sort (1 test)order: { priority: 'ASC', createdAt: 'DESC' } (a stable mixed sort: explicit operator priority wins, ties break newest-first — pinned so a future "createdAt only" tweak is deliberate).
  • findEnabledByWork distinct ordering (1 test)priority: 'ASC' only (NO createdAt tie-breaker — pinned distinct from findByWork because the runtime-registry consumer doesn't need the tie-breaker; caller-side capability resolution handles ties).
  • findActiveByCapability driver-agnostic filter (3 tests) — queries find for enabled: true rows + pluginEntity relation, then filters in JS via hasActiveCapability (driver-agnostic — same reason as PluginRepository.findByCapability); null passthrough; Array.find first-match semantics pinned (two plugins claiming the same active capability is an invariant violation but the method returns the first one — pinned so a future "all-matches" refactor is deliberate).
  • findByPlugin / findEnabledByPlugin (2 tests)findByPlugin includes the work relation (inverse load); findEnabledByPlugin does NOT load relations (the lifecycle manager consumer doesn't need them — pinned so a future "always include work" tweak is deliberate).
  • update / updateByWorkAndPlugin / updateSettings (4 tests) — UPDATE + refetch pattern (TypeORM update has no entity payload); updateSettings empty-object secretSettings forwarded verbatim, NOT collapsed to undefined (predicate !== undefined, matching UserPluginRepository); secretSettings omitted when undefined.
  • setActiveCapability (4 tests) — null passthrough when row doesn't exist (NO UPDATE issued, defensive); capability === null clears activeCapabilities to [] (empty array, NOT undefined — pinned so the field is reset rather than left with old value); appends to existing list via addActiveCapability (Set-deduped); idempotent when capability already present.
  • clearActiveCapability (3 tests) — finds all rows for the work and removes the capability ONLY from rows that had it; row-count return value (NOT boolean); fast path (NO relations hint on the find — clear-only doesn't need the join, pinned distinct from the regular finders).
  • setAsActiveForCapability two-step ordering (1 test) — runs clearActiveCapability BEFORE setActiveCapability (verified via shared callOrder array — the clear's find+save calls must precede the set's update call). This ordering enforces the single-active-plugin-per-capability invariant: if setActiveCapability fails after clearActiveCapability, the work is left with NO active plugin for that capability (preferable to two simultaneously-active providers).
  • setEnabled / setPriority (4 tests) — UPDATE by composite key + (affected ?? 0) > 0 predicate; both methods return false on undefined affected.
  • delete* family (5 tests)delete by id + deleteByWorkAndPlugin return boolean; deleteByWork and deleteByPlugin return integer count (NOT boolean — pinned distinct because the work-deletion / plugin-uninstall flows log how many rows were swept); 0 fallback on undefined affected.
  • exists (3 tests)count (cheaper than findOne) + > 0 predicate (NOT === 1).
  • upsert (2 tests) — finds existing → UPDATE + refetch (NO create/save call); finds none → create + save (NO update call).

Total agent-package suite: +38 tests across 1 new suite, all green.


2026-05-09 — packages/agent PluginRepository direct coverage (+37 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #678)

Closes the per-file zero-coverage gap on packages/agent/src/plugins/repositories/plugin.repository.ts (183 LOC) — the TypeORM-wrapper repository for plugins rows (the pluginId-unique catalogue of installed plugin entities + their lifecycle state + scoped settings). The repository is consumed across PluginRegistryService, PluginLifecycleManagerService, PluginSettingsService, and the apps/api plugins controllers; pinning the query shape protects every downstream caller from a silent driver swap or a relation-key change. The new file is packages/agent/src/plugins/repositories/plugin.repository.spec.ts and pins:

  • create (1 test) — proxies through repository.create(data) then repository.save(...).
  • findByPluginId / findById (4 tests)where: { pluginId } / where: { id } lookup, NO relation hints (plugins is a flat table — pinned so a future "join in a relation" tweak is deliberate); null passthrough when no row.
  • findAll defensive where-builder (6 tests) — omits where entirely (returns find({order: name ASC}) w/o a where key) when no options are provided OR when all options are undefined; builds a where with each provided field individually; combines multiple fields when present (category + state + builtIn); builtIn: false STILL adds the filter — the predicate is !== undefined (NOT truthy-check) so a literal false is NOT silently dropped; always orders by name: ASC (alphabetical UI list).
  • findByCategory (1 test)where: { category } + order: { name: 'ASC' }.
  • findByCapability in-memory filter (2 tests) — pulls the full table then filters in-memory by capabilities.includes(...) (intentional — the array-typed capabilities column has driver-specific SQL handling: Postgres &&/@>, MySQL JSON_CONTAINS, sqljs has neither — the in-memory filter keeps the repository driver-agnostic, pinned so a future "switch to SQL-side array contains" optimisation is a deliberate, driver-targeted diff); empty-array fallback when no plugin advertises the capability.
  • findByPluginIds empty-array short-circuit (2 tests) — short-circuits to [] on empty input WITHOUT touching repository.find (avoids the invalid WHERE pluginId IN () SQL); forwards non-empty IDs via TypeORM In(...).
  • updateByPluginId / update (by id) (3 tests) — UPDATE + re-fetch via findByPluginId/findById (TypeORM's Repository.update resolves to an UpdateResult w/ no entity — every caller depends on the refetch); null passthrough when refetch finds nothing.
  • updateState (5 tests) — writes new state via updateByPluginId; does NOT stamp loadedAt for non-loaded states (e.g. 'error', pinned because loadedAt is the "last SUCCESSFUL load" timestamp shown in the UI — a future "always update loadedAt" tweak that would make every error event LOOK like a successful load is a deliberate diff); STAMPS loadedAt = new Date() only when state === 'loaded' (verified within before/after Date.now() window); forwards error when provided — predicate error !== undefined (NOT truthy-check) so empty-string clears prior error rather than being silently dropped; omits lastError from write when error arg is undefined.
  • updateSettings (3 tests) — writes only settings when secretSettings is omitted; includes secretSettings when provided; CRITICAL contract: secretSettings = {} (empty object) forwarded verbatim, NOT collapsed to undefined (predicate secretSettings !== undefined).
  • deleteByPluginId / delete (by id) (5 tests)(affected ?? 0) > 0 predicate; both methods return false on 0 / undefined.
  • exists (3 tests) — uses count (NOT findOne) for cheaper presence check; predicate > 0 (NOT === 1) — defensive against driver-level duplicates; false on count === 0.
  • upsert (2 tests) — finds existing → UPDATE + refetch (NO create/save call); finds none → create then save (NO update call).

Total agent-package suite: +37 tests across 1 new suite, all green.


2026-05-09 — packages/agent UserPluginRepository direct coverage (+33 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #677)

Closes the per-file zero-coverage gap on packages/agent/src/plugins/repositories/user-plugin.repository.ts (165 LOC) — the TypeORM-wrapper repository for user_plugins rows (the (userId, pluginId)-unique table that holds per-user plugin enable state + scoped settings). The repository is consumed across PluginRegistryService, PluginSettingsService, PluginOperationsService, and the apps/api controllers; pinning the query shape protects every downstream caller from a silent driver swap or a relation-key change. The new file is packages/agent/src/plugins/repositories/user-plugin.repository.spec.ts and pins:

  • create (1 test) — proxies through repository.create(data) then repository.save(...) (return value forwarded verbatim).
  • findByUserAndPlugin (2 tests) — composite-key where: { userId, pluginId } + pluginEntity relation hint; null passthrough when no row.
  • findById (2 tests)where: { id } + pluginEntity relation; null passthrough.
  • findByUser (1 test)where: { userId } + pluginEntity relation + order: { createdAt: 'DESC' } (the "most-recently-added plugin first" invariant pinned so a future "switch to ASC" tweak is deliberate).
  • findEnabledByUser (1 test)where: { userId, enabled: true } + pluginEntity relation, NO order key (consumer iterates all matched rows or applies its own ordering — pinned so a default order doesn't drift back in by accident).
  • findByPlugin (1 test)where: { pluginId } + user relation (the inverse — given a pluginId, load all users with a record). Pinned distinct from the other lookups which use the pluginEntity relation.
  • update (2 tests) — UPDATEs by id, then re-fetches via findById (TypeORM's Repository.update resolves to an UpdateResult w/ no entity — every caller depends on the refetch); returns null when the post-UPDATE refetch finds nothing (e.g. concurrent delete).
  • updateByUserAndPlugin (2 tests) — UPDATEs by composite key, then refetches via findByUserAndPlugin; null passthrough when refetch finds nothing.
  • updateSettings (4 tests) — passes only settings when secretSettings is omitted; includes secretSettings when provided; CRITICAL contract: secretSettings = {} (empty object) is forwarded verbatim, NOT collapsed to undefined — the predicate is secretSettings !== undefined (NOT truthy-check), which is how callers clear all secrets back to "none"; returns the refetched entity.
  • setEnabled (4 tests) — UPDATEs enabled by composite key, returns true on affected > 0; returns false on affected === 0; returns false on affected === undefined (defence-in-depth — (result.affected ?? 0) > 0 evaluates undefined ?? 0 > 0 → 0 > 0 → false, pinned so a future "rely on truthiness" tweak that treats undefined as true is deliberate); enabled flag forwarded verbatim (false vs true).
  • delete (3 tests) — true on affected > 0, false on 0 / undefined.
  • deleteByUserAndPlugin (2 tests) — DELETE by composite key; same boolean return contract.
  • deleteByPlugin (3 tests) — returns the integer affected count (NOT a boolean — pinned distinct from the other delete methods because the plugin-uninstall flow logs how many user rows were swept); 0 fallback for missing/undefined affected.
  • exists (3 tests) — uses count (cheaper than findOne) with composite-key where; predicate is > 0 (NOT === 1) so count === 2 still returns true (defensive against driver bug); false on count === 0.
  • upsert (2 tests) — finds existing → UPDATE + refetch (NO create/save call); finds none → create then save (NO update call).

Total agent-package suite: +33 tests across 1 new suite, all green.


2026-05-09 — packages/agent DatabaseModule decorator-metadata coverage (+15 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #676)

Closes the per-file zero-coverage gap on packages/agent/src/database/database.module.ts (96 LOC) — the central NestJS module that all packages/agent and apps/api feature modules import to resolve TypeORM-backed repositories. database-config.factory.spec.ts and database.config.spec.ts already cover the connection-options factory and entities list; this new spec restricts itself to the static @Module() decorator metadata so the wire surface (imports / providers / exports lists) is pinned regardless of the runtime DataSource. The new file is packages/agent/src/database/database.module.spec.ts and pins:

  • @Module() providers (4 tests) — every documented repository class is present in the providers list (23 of them, kept in a typed REPOSITORY_PROVIDERS constant in the spec so adding/removing a repository is an explicit, type-checked update); EXACTLY 23 providers (regression guard against silent additions); every provider is a class constructor (function with prototype) — pinned so a future useClass/useFactory swap is deliberate; DataSource/EntityManager are NOT directly provided (consumers depend on the typed wrappers).
  • @Module() exports (4 tests)TypeOrmModule is exported (so consumers can @InjectRepository(...) even though the binding lives in this module's scope); every documented repository is exported; exactly 24 symbols (TypeOrmModule + 23 repositories); the providers-superset-of-exports invariant pinned (every provider is also exported — no "private helper" repositories silently held back).
  • @Module() imports (5 tests) — exactly 2 TypeOrmModule entries (the forRootAsync connection + the forFeature(ENTITIES) per-entity binding); exactly 1 ConfigModule.forFeature(databaseConfig) entry (pinned because config.get('database') depends on the forFeature scoping); exactly 3 imports total (regression guard); the forRootAsync DynamicModule wraps a single ConfigModule import so the inner factory can resolve ConfigService (a future "drop the imports forwarding" tweak would break the inner factory loudly); the forFeature DynamicModule binds providers + exports (pinned so a future "stop calling forFeature" tweak that orphans every @InjectRepository(...) consumer is a deliberate diff).
  • Class identity (2 tests)DatabaseModule is a class function (so DI-resolution by class identity works); the documented class name 'DatabaseModule' is preserved (so a string-based registry would still find it).

Total agent-package suite: +15 tests across 1 new suite, all green.


2026-05-09 — packages/agent plugins.constants direct coverage (+35 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #672)

Closes the per-file zero-coverage gap on packages/agent/src/plugins/plugins.constants.ts (66 LOC) — a contracts-only surface that declares the PLUGINS_MODULE_OPTIONS DI token, the DEFAULT_PLUGIN_PATHS discovery list, the DEFAULT_PLATFORM_VERSION fallback, the PluginStates literal map (5 lifecycle states), the VALID_STATE_TRANSITIONS adjacency map (the lifecycle state machine), the PluginEvents EventEmitter2 event registry (7 events) consumed across plugin-registry.service, plugin-lifecycle-manager.service, and plugin-settings.service, and the four-tier SETTING_SOURCE_PRIORITY cascade enforced by plugin-settings.service. Every value here is read in production by string-match (PluginEvents.LOADED'plugin:loaded'); silently changing one literal would orphan every existing listener / persisted state row. The new file is packages/agent/src/plugins/__tests__/plugins.constants.spec.ts and pins:

  • PLUGINS_MODULE_OPTIONS (2 tests)Symbol(...) shape + documented description; created via Symbol() NOT Symbol.for() (so two independently-instantiated PluginsModules in the same process don't accidentally collide their DI bindings via the global registry).
  • DEFAULT_PLUGIN_PATHS (3 tests) — exact 5-entry list pinned in declaration order (loader walks paths in declaration order; ./plugins deliberately precedes ./packages/plugins so a user-installed plugin wins over a built-in copy); every entry is a ./ or ../ relative path; uniqueness check.
  • DEFAULT_PLATFORM_VERSION (2 tests)'0.1.0' literal pin + semver MAJOR.MINOR.PATCH shape regex.
  • PluginStates (5 tests) — exact 5-key literal map pinned (UNLOADED/LOADING/LOADED/UNLOADING/ERROR → lowercase wire literals); key-set regression guard; value-uniqueness; lowercase-only invariant (matches PluginState union from @ever-works/plugin); as const JSON round-trip.
  • VALID_STATE_TRANSITIONS adjacency map (9 tests) — every documented edge pinned literally (unloaded → loading; loading → loaded|error; loaded → unloading; unloading → unloaded|error; error → loading|unloading); has-entry-for-every-state lower-bound; no-dangling-edges (every target is itself a documented PluginState); no-self-transitions invariant (a future "idempotent re-load" tweak would be a deliberate diff); unloaded → loaded blocked (must go through loading first — preserves the "currently-initializing" intermediate state for guards); error recovery only via loading/unloading (NOT directly to loaded/unloaded — would bypass re-validation); LOADING → ERROR and UNLOADING → ERROR both pinned (so a future "always go to LOADED/UNLOADED" tweak would mask init/teardown failures); every adjacency list non-empty.
  • PluginEvents (6 tests) — exact 7-key literal map pinned (LOADED/UNLOADED/ERROR/SETTINGS_CHANGED/STATE_CHANGED/REGISTERED/UNREGISTEREDplugin:<kebab-case> wire format); key-set regression guard; uniqueness; plugin:<name> namespace regex; : (NOT .) separator invariant — EventEmitter2's wildcard listeners (plugin:*) split on the configured delimiter so switching to . would silently break every existing wildcard listener; as const JSON round-trip.
  • SETTING_SOURCE_PRIORITY cascade (7 tests) — exact 5-tier list pinned (work → user → admin → env → default highest-to-lowest); length regression guard; per-tier uniqueness; lowercase identifier invariant; work ahead of user (per-work overrides win over per-user defaults); default last (the schema-declared default is always the last-resort tier); env ahead of default (env-var fallback wins over schema default — matches x-envVar extension semantics).
  • Barrel re-exports (1 test) — pins that plugins/index.ts re-exports every documented runtime symbol so deleting the export * from './plugins.constants' line is a loud diff.

Total agent-package suite: 3693 → 3728 tests across 171 → 172 suites, all green. Closes the smallest non-DTO/entity uncovered file in the plugins/ subdirectory.


2026-05-10 — packages/agent PipelineFacadeService direct coverage (+30 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #680)

Closes the per-file zero-coverage gap on packages/agent/src/pipeline/pipeline-facade.service.ts (300 LOC) — the facade-binding service that creates the StepExecutionContext consumed by both pipeline executors (StepPipelineExecutorService and FullPipelineExecutorService). Every pipeline-step plugin call goes through one of the bound facades produced here, so a regression in the aiModelOverride precedence rules or providerOverrides plumbing would silently route every step through the wrong provider.

The new file is packages/agent/src/pipeline/pipeline-facade.service.spec.ts and pins:

  • Guard rails (4 tests) — throws 'User context is required for pipeline execution. Ensure WorkReference includes a user with an id.' when work.user is undefined OR work.user.id is empty/falsy; happy path returns the documented 10-key envelope (6 bound facades + logger + work + user + signal); signal forwarded verbatim (undefined preserved, NOT coerced to null).
  • Logger prefix (2 tests) — every log/debug/warn/error/verbose call wrapped with [work.slug] prefix; verbose?.() optional-chain tolerates an undefined logger.verbose method (so a Logger implementation that doesn't expose verbose doesn't crash).
  • Bound AI facade (9 tests)askJson forwards (promptTemplate, schema, options, {workId, userId, providerOverride: ai}); CRITICAL contract: aiModelOverride defaults into options.routing.modelOverride via ??-precedence — a caller-supplied override wins over the binding-context default (a future "binding wins" refactor breaks deliberately); createChatCompletion defaults options.model to aiModelOverride via the same ??-rule; createStreamingChatCompletion applies the same defaulting; isConfigured proxies through with NO arg (the bound version drops the _facadeOptions param at the call site); testConnection / getAvailableModels / getProviderConfig / resolveModelMetadata / resolveModelContextLength all forward bound options.
  • Bound search facade (3 tests)search(query, options, ...) forwards {userId, workId, providerOverride: providerOverrides?.search}; providerOverride: undefined when no override; isConfigured no-arg passthrough.
  • Bound screenshot facade (2 tests)capture / getSmartImage / getScreenshotUrl all forward {workId, userId, providerOverride: providerOverrides?.screenshot}; isAvailable + isConfigured no-arg passthrough.
  • Bound content-extractor facade (2 tests)extractContent + extractContentWithDiagnostics forward {userId, workId, providerOverride: providerOverrides?.contentExtractor}; isConfigured no-arg passthrough.
  • Bound prompt facade (2 tests)getPrompt(key, defaultPrompt, ...) forwards bound options w/ NO providerOverride field (prompts have no per-provider-override semantics); isConfigured no-arg passthrough.
  • Bound data-source facade (5 tests)queryAll spread-order pin: caller-supplied workId/userId is OVERWRITTEN by the binding because the wrapper spreads caller options FIRST then sets workId/userId last (a future "trust the caller" refactor breaks deliberately); getEnabledSources(workId, userId) accepts caller userId when truthy but falls back to bound userId when caller passes ''/null/undefined (the userId || ctx.userId branch); isConfigured no-arg passthrough; the @Optional() data-source dep — when undefined, the bound dataSourceFacade field on the returned context is also undefined.
  • Binding context shape (1 test) — non-AI bound facades do NOT carry aiModelOverride into the underlying facade-options call (only the AI facade applies that field via the routing.modelOverride path).

Total agent-package suite: 3873 → 3903 tests across 177 → 178 suites, all green. The packages/agent/src/pipeline/ zero-coverage gap is now empty for service-level files (orchestrator + step + full + facade all covered; only the executable-pipeline.class.ts and step-pipeline-executor.service.ts private internals remain).


2026-05-09 — packages/agent FullPipelineExecutorService direct coverage (+22 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #674)

Closes the per-file zero-coverage gap on packages/agent/src/pipeline/full-pipeline-executor.service.ts (239 LOC) — the executor that runs self-managed pipeline plugins (e.g. claude-code, where the plugin owns step ordering and orchestration internally rather than delegating each step back to the engine). The orchestrator routes self-managed pipelines through this service, so a regression in event emission, validation, or cancellation wiring is invisible to the existing PipelineOrchestratorService suite (which mocks the executor).

The new file is packages/agent/src/pipeline/full-pipeline-executor.service.spec.ts and pins:

  • execute happy path (4 tests) — emits pipeline:started BEFORE invoking plugin.execute (asserted via mock.calls.find on the emit ordering); calls facadeService.createStepExecutionContext(work, request.providers, request.aiModel, options?.signal) to build the per-execution context; passes {...options, execContext, onLogEntry: options?.onLogEntry} into plugin.execute(work, request, existing, ...); emits pipeline:completed w/ {workId, pipelineId, duration, stepsCompleted, outputs} envelope on success; CRITICAL contract: the wrapper REPLACES the plugin's reported duration field with its own Date.now() - startTime measurement (a plugin returning a stale duration: 9999 will be overridden — pinned via fake timers); signal forwarded into the facade context; onProgress callback forwarded verbatim into plugin.execute.
  • execute log-interceptor lifecycle (5 tests) — when options.onLogEntry is set, contextFactory.addLogInterceptor(plugin.id, fn) is invoked and the returned remove fn is called in the finally block; the interceptor's (level, message) arguments are routed into the documented envelope {timestamp, level, source: 'pipeline', event: 'message', message} (the level cast to GenerationStepLog['level'] is type-only — runtime forwarding is verbatim); the interceptor is correctly removed even when plugin.execute rejects (finally-block invariance); without onLogEntry, addLogInterceptor is NEVER called and the optional-call guard removeInterceptor?.() short-circuits cleanly.
  • execute invalid-result handling (4 tests) — when plugin.execute returns a non-object (string, primitive), validatePipelineResult produces a single 'Result must be an object' error and the wrapper throws Plugin "<id>" returned invalid pipeline result: <errors>; the catch path emits pipeline:failed ONCE per execute call (no double-emission across the inner-throw and outer-catch), then returns buildErrorPipelineResult(...) w/ totalSteps sourced from plugin.getStepDefinitions().length (so the failure envelope is still informative); multi-error case → errors joined with '; '; plugin.execute rejection → FAILED w/ error.message + completedSteps: 0 and the same error-envelope shape.
  • executeWithCancellation (4 tests) — when both plugin.cancel AND options.signal are present, signal.addEventListener('abort', handler, {once: true}) is registered and a removeEventListener is queued in the finally block; firing abort on the signal triggers plugin.cancel() exactly once; plugin.cancel rejection is swallowed via .catch(err => logger.error(...)) so the abort handler does not propagate; when plugin.cancel is undefined, the abort wiring is skipped entirely (no addEventListener call); listener removal in finally runs even when execute throws (the wrapper still recovers via the inner try/catch and returns an error envelope, so the finally is reached).
  • getPluginState (2 tests) — proxies plugin.getState() when defined; returns null when plugin.getState is undefined.
  • Event emission helpers (3 tests, exercised via execute)emitPipelineEvent merges {timestamp, ...payload} (the spread runs after the timestamp default, so an explicit payload.timestamp would win — pinned via the default-ISO branch); emitPipelineCompleted forwards outputs from the validated result (NOT the raw plugin return); emitPipelineFailed envelope shape {timestamp, workId, pipelineId, error, failedStep:undefined, completedSteps:0} pinned literally via toEqual so a future "add failedStepLogs" addition is a deliberate change.

Total agent-package suite: 3693 → 3715 tests across 171 → 172 suites, all green.


2026-05-09 — packages/agent WorksConfigImportApplierService direct coverage (+37 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #671)

Closes the per-file zero-coverage gap on packages/agent/src/works-config/services/works-config-import-applier.service.ts (120 LOC) — the last remaining service file in the works-config/ subdirectory without co-located coverage. After the planner has decided an import is safe, the applier translates the parsed .works/works.yml payload into actual side-effects on the user's work: enables the Codex / Claude-Code pipeline plugin and configures the WorkSchedule. Both schedule paths intentionally swallow rejections from WorkScheduleService and downgrade them to logger.warn lines — pinned because a future "rethrow on schedule failure" refactor would silently break end-to-end imports for users with one bad provider. The new file is packages/agent/src/works-config/__tests__/works-config-import-applier.service.spec.ts and pins:

  • applyPipelineSettings (8 tests) — short-circuit on null/undefined worksConfig; short-circuit when model is missing (getPipelinePluginSettings private helper); short-circuit when pipelineId is anything OTHER than the two literal eligibility values 'codex' or 'claude-code' (parametrized across agent-pipeline / claude-managed-agent / gemini / opencode / unknown-pipeline / undefined — pinned so a future "extend the eligibility list" change is a deliberate addition); happy-path forwarding to pluginOperationsService.enablePluginForWork(workId, pluginId, userId, {activeCapability:'pipeline', settings:{model}}) for both eligible pipelines; positional-args contract pinned via mock.calls[0]-length-4 (so a future single-object-parameter refactor breaks loudly); rejection from enablePluginForWork is RE-THROWN — pinned contrast with the schedule paths' swallow-and-warn behaviour.
  • applyInitialSchedule (9 tests) — short-circuit on null/undefined worksConfig; short-circuit when scheduleCadence is missing or null (the !worksConfig?.scheduleCadence guard); the four documented defaults pinned verbatim (enable:true, cadence, alwaysCreatePullRequest:true, providerOverrides); empty-object providers coerced to null for providerOverrides (via Object.keys(...).length > 0 gate); non-empty providers forwarded verbatim; Error rejection from updateSchedule is logged via logger.warn('Failed to restore schedule from .works/works.yml for work <workId>: <message>') and SWALLOWED; non-Error rejection coerced via String(error) in the warn message (string + number both pinned).
  • applyScheduleOverrides (12 tests) — short-circuit when work.scheduledUpdatesEnabled === false; short-circuit when neither cadence NOR providers is set (the (!cadence && !providers) AND-gate — providing either one is enough to proceed, pinned); short-circuit on null/undefined worksConfig; pinned contrast with applyInitialSchedule: only cadence and providerOverrides are forwarded — enable and alwaysCreatePullRequest are deliberately omitted so the operator's existing settings are preserved; pinned contrast with the initial-schedule path: an EMPTY providers object is forwarded verbatim (NOT coerced to null) because the gate uses providers !== undefined, allowing the operator to clear all overrides explicitly; undefined providerOverrides is forwarded only when providers is omitted entirely; Error and non-Error rejections both swallowed with the documented 'Failed to restore schedule overrides from .works/works.yml for work <id>' warn copy; the warn message uses work.id (NOT a workId arg) — pinned via a custom-id work fixture.
  • Contracts (1 test) — service exposes a NestJS Logger instance.

The spec mocks @src/services/work-schedule.service and @src/plugins/services/plugin-operations.service at module scope — the real WorkScheduleService transitively imports the ESM-only p-map (via data-generator.service.ts) which Jest's CJS transformer cannot parse. Total agent-package suite: 3656 → 3693 tests across 170 → 171 suites, all green. The works-config submodule per-file coverage is now complete.


2026-05-09 — packages/agent error-classification.utils direct coverage (+68 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, [PR pending])

Closes the per-file zero-coverage gap on packages/agent/src/services/utils/error-classification.utils.ts (133 LOC) — a substring-pattern dispatcher used by WorkGenerationService.handleErrorNotification to map raw provider/git errors into one of five user-facing notification flavours. The classification rules are documented order-sensitive (ai_creditsai_providergit_authaccount_levelunknown) AND substring-overlap-sensitive: 'authentication' matches BOTH isAiProviderError AND isGitAuthError's second leg, so a git authentication failure is only classified as git_auth when the message ALSO contains 'git'/'github'/'gitlab' AND the second leg keyword is one of token/expired/permission denied (the only ones not already in ai_provider's pattern). The new file is packages/agent/src/services/utils/error-classification.utils.spec.ts and pins:

  • Input shape coercion (8 tests)Error/TypeError .message passthrough; raw-string passthrough; String(error) coercion for number/null/undefined/object-literal ('404'/'null'/'undefined'/'[object Object]'); the errorLower lowercasing is internal-only (the returned result.message preserves the original case so a UI rendering it preserves the upstream casing).
  • ai_credits priority (8 tests) — every documented substring (insufficient_quota, rate_limit, quota exceeded, credits, billing, exceeded your current quota); case-insensitive match via internal lowercasing; beats ai_provider AND account_level when both substrings co-occur (priority pin so a future re-ordering of the four if checks is a deliberate behavioural shift).
  • ai_provider (6 tests) — every documented substring (invalid_api_key, authentication, unauthorized, api key); mixed-case API Key lowercasing; does NOT also classify as git_auth for a plain 'authentication failure' (the git_auth path requires both legs of an AND).
  • git_auth (8 tests) — only reachable via the second-leg keywords NOT in ai_provider (token/expired/permission denied) combined with git/github/gitlab; explicitly pinned that 'github authentication failed' and 'gitlab unauthorized' are intercepted by ai_provider first (priority pin); the AND-gate first leg pinned (a bare 'git pull failed' falls through to unknown).
  • account_level (5 tests)account / subscription / plan limit / not configured; emits empty provider.
  • unknown fallback (4 tests) — random/empty inputs; pinned to return provider: '' and message verbatim.
  • detectAiProvider (10 tests) — exercised via ai_credits/ai_provider paths: openaiOpenAI, anthropic/claudeAnthropic, google/geminiGoogle, groqGroq, ollamaOllama, openrouterOpenRouter, fallback 'AI Provider'; first-match-wins ordering pinned (openai beats anthropic; claude (Anthropic alias) beats google).
  • detectGitProvider (4 tests)github/gitlab/bitbucket mapping; fallback 'Git Provider' for the bare git keyword; first-match ordering (github beats gitlab when both appear).
  • Return shape contract (2 tests) — every return is exactly {type, provider, message}; type is always one of the five documented ErrorClassificationType literals.
  • notifyForClassifiedError dispatch routing (5 tests)ai_creditsnotifyAiCreditsDepleted(userId, provider, message); ai_providernotifyAiProviderError(userId, provider, message); git_authnotifyGitAuthExpired(userId, provider) (message DROPPED — pinned via call-args length); account_levelnotifyGenerationAccountError(userId, workId, workName, message) (provider DROPPED); unknown → silent no-op (no notifier invoked, the switch has no default branch).
  • Rejection propagation (2 tests) — Error rejection from a notifier propagates verbatim; non-Error rejection (e.g. a string) is NOT String-coerced — propagated by-reference.
  • Await semantics (1 test) — the chosen notifier is awaited before notifyForClassifiedError resolves (sequential ordering pin via shared ops array + microtask boundary).

Total agent-package suite: 3588 → 3656 tests across 169 → 170 suites, all green.


2026-05-09 — packages/agent PipelineOrchestratorService + comparison/types direct coverage (+44 tests across 2 new specs, scheduled-task platform-tests-and-docs cycle, #668)

Two previously-uncovered files in the agent package closed in a single sweep (the parallel scheduled-task run combined them on a shared branch):

packages/agent/src/comparison-generator/comparison/types.spec.ts (11 tests)

Type-/contract-level coverage for comparison/types.ts (60 LOC) — the contracts module that declares the runtime DEFAULT_COMPARISON_SETTINGS constant plus the ComparisonProgressStage literal union, ComparisonPair, ComparisonResearch, ComparisonGenerationResult, ComparisonProgressInfo, ComparisonProgressCallback, and ComparisonPluginSettings types consumed across the comparison-generator sub-module. Pins: every documented field of DEFAULT_COMPARISON_SETTINGS; the four required defaults (silent change of any one would alter behaviour on every existing work) — temperature/itemsPerCategory/maxComparisons/category defaults; the as const-narrowed ComparisonProgressStage union (every documented stage literal accepted by a ComparisonProgressCallback parametric in the union); minimal-and-maximal ComparisonPair / ComparisonResearch / ComparisonGenerationResult / ComparisonProgressInfo literal shapes type-check; optional extendedAnalysisMarkdown accepted on the result; pluginSettings shape with the documented optional fields.

packages/agent/src/pipeline/pipeline-orchestrator.service.spec.ts (33 tests)

Closes the per-file zero-coverage gap on packages/agent/src/pipeline/pipeline-orchestrator.service.ts (262 LOC) — the central routing service that picks between step-orchestrated (e.g. standard-pipeline) and self-managed (e.g. claude-code) pipeline plugins. Every entry-point through which the API or scheduler executes a generation flows through this orchestrator's resolution + dispatch logic, so a regression in routing or auto-detect priority is invisible to the existing WorkGenerationService suite (which mocks the orchestrator).

Pins:

  • execute (4 tests) — step-orchestratable plugin → stepExecutor.execute(plugin, work, request, existing, options, onProgress) w/ exact positional shape; self-managed plugin → fullExecutor.execute(...) (mutually exclusive — neither executor is double-called); request.providers.pipeline honoured as the explicit pipeline id (wins over defaultForCapabilities); options + onProgress callback forwarded verbatim into the chosen executor.
  • executeWithMode (3 tests) — forced step mode resolves through resolvePipelinePlugin auto-detect chain (NOT the explicit-id branch); forced full mode finds the first self-managed (non-step-orchestratable) plugin in the registry and routes to fullExecutor; full→step fallback w/ logger.warn('Full mode requested but no self-managed pipeline available, falling back to step mode') when no self-managed plugin exists.
  • getRecommendedMode (3 tests) — self-managed plugin available → {mode:'full', reason:'Self-managed pipeline plugin "<name>" is available', plugin: '<id>'}; only step-orchestratable plugins → {mode:'step', reason:'No self-managed pipeline plugin available'} w/ NO plugin field; empty registry → still resolves to mode:'step'.
  • hasFullPipelinePlugin (3 tests)true when at least one self-managed plugin is loaded; false when only step-orchestratable plugins exist; false when registry is empty.
  • getAvailablePipelinePlugins (2 tests) — registry filter chain pinned: getByCapability(PLUGIN_CAPABILITIES.PIPELINE)state === 'loaded'.map(p => p.plugin)isPipelinePlugin shape filter (so a plugin registered against PIPELINE capability but missing 'pipeline' from plugin.capabilities is still rejected — defence in depth); empty registry → [].
  • resumeFromCheckpoint (2 tests) — resolves the pipeline plugin via resolvePipelinePlugin then proxies positional args (plugin, workId, pipelineId, options, onProgress) to stepExecutor.resumeFromCheckpoint; returns the result by reference; null from the step executor (no-checkpoint case) is forwarded verbatim.
  • clearCheckpoint (1 test) — proxies (workId, pipelineId) to stepExecutor.clearCheckpoint.
  • resumeOrExecute (3 tests) — when the resolved plugin is step-orchestratable AND a checkpoint exists, returns the resumed result without calling execute (neither executor invoked freshly); when the plugin is step-orchestratable but no checkpoint exists, falls through to fresh execute (which routes back to stepExecutor); when the plugin is self-managed, skips resumeFromCheckpoint entirely (the isStepOrchestratablePipeline(plugin) gate prevents calling resume on plugins that don't support it) and routes straight to fullExecutor.execute.
  • resolvePipelinePlugin private (15 tests, exercised via execute) — explicit pipelineId hits registry.get + state === 'loaded' + isPipelinePlugin + isPluginEnabledForScope(pluginId, workId, userId) four-way gate; falls back to auto-detect (with logger.warn('Pipeline plugin "<id>" not available, falling back to auto-detect')) when not registered, when registered-but-unloaded, OR when registered-but-not-enabled-for-scope; auto-detect first-pass prefers manifest.defaultForCapabilities.includes('pipeline') plugins (registry order does NOT matter — the default-tagged plugin wins even if listed last); auto-detect skips unloaded plugins on the default-pass; auto-detect skips a default-tagged plugin when scope-disabled and falls through to the first-loaded-and-enabled in the second pass; null pipelineId skips the registry.get path entirely (the typeof pipelineId === 'string' guard rejects null); plugins with wrong capabilities (e.g. ['search']) are skipped via isPipelinePlugin; throws Error('No pipeline plugin available. Ensure at least one pipeline plugin is loaded.') when no plugin satisfies all gates; work.id + work.user?.id (with work.user undefined → undefined userId) forwarded into the scope check.

Total agent-package suite: 3474 → 3507 tests across 165 → 166 suites, all green.


2026-05-09 — works-config + WorkModule wiring coverage sweep #8 (+70 tests across 2 new specs, scheduled-task platform-tests-and-docs cycle, #665 / #666)

Two previously-uncovered agent-package wiring/delegate files closed:

  • packages/agent/src/works-config/__tests__/works-config-restore.service.spec.ts (35 tests, #665) — WorksConfigRestoreService (92 LOC) is a thin delegate over WorksConfigImportPlannerService and WorksConfigImportApplierService. A silent typo on a delegate signature would route a write through the wrong collaborator without any test catching it. Pins: each of 11 public methods delegates to the correct collaborator (8 to planner, 3 to applier) with the exact positional args, returning the collaborator's result by reference where applicable; undefined (omitted optional args) vs null (explicit null where the type allows it) forwarded as distinct passes; validateForImport / validateProviderSettings / validateRepositoryTargets (Promise<void> / void) — the wrapper does NOT leak the collaborator's return value, even when the collaborator returns a non-undefined value; async delegate rejection propagation pinned independently for the planner and applier sides; sync delegate throw propagation; cross-method invariant — applyPipelineSettings does NOT touch the planner (boundary check that catches a misrouted delegate); public-surface invariants — every planner method maps to one public method; every applier method maps to one public method (so a future delegate addition gets caught). Mocks the planner and applier modules at module scope to avoid the transitive ESM-only p-map import path that would otherwise crash the test runner under Jest's CJS transformer.
  • packages/agent/src/services/work.module.spec.ts (35 tests, #666) — WorkModule is the central composition root for ~26 services (plus 3 transitively re-exported feature modules). A single typo in providers/exports would silently break a downstream consumer. Pins: documented 12-module imports list + length regression guard; PluginsModule deliberately NOT imported (the JSDoc documents that it is registered globally via forRoot at the app root — a future imports: [..., PluginsModule] would double-register); every documented service identity in providers + the 26-provider count; WorksConfigSyncListener registration pinned (without it the @OnEvent dispatcher cannot discover the works-config sync handler — a "remove unused listener" cleanup would silently break the sync feature); every provider IS exported EXCEPT WorksConfigSyncListener, SettingsSchemaValidatorService, and PluginOperationsService (intentional internal-only); the 3 feature modules (CommunityPrModule / ComparisonGeneratorModule / TemplateCatalogModule) re-exported; boundary check — exports does NOT re-export DatabaseModule (callers must import it explicitly when they need entities/repositories; a re-export would silently grant every WorkModule consumer access to the entire entity tree). Mocks every transitive module/service with empty class shells at module scope to keep the heavy ESM-only / TypeORM trees out of the Jest CJS path.

2026-05-09 — security + utility coverage sweep #7 (+106 tests across 3 new specs + 1 flake-fix, scheduled-task platform-tests-and-docs cycle, #660 / #661 / #662 / #663)

Three previously-uncovered agent-package utility files plus an unrelated CI-blocking flake fix in the gemini-plugin test suite:

  • packages/agent/src/utils/__tests__/work-changelog.utils.spec.ts (23 tests, #661) — buildWorkChangelog (the single producer of the WorkChangelog envelope written into work_generation_history rows and surfaced in API responses). Pins: empty-input contract (returns null even when an explicit summary is supplied — length-zero gate runs first), count partitioning (entries.filter(e => e.action === ...).length for added/updated/removed independently), entries reference passthrough, summary override (explicit summary wins; empty-string preserved because ?? only short-circuits null/undefined; null/undefined fall back to default builder), default summary builder (per-part pluralisation item vs items, fixed , join order added → updated → removed, zero-count parts omitted entirely), entityType label resolution across all 5 documented literals (item/comparison/category/tag/collection) with entries[0]-only derivation pinned (mixed-type lists are summarised under the first entry's label — a future "scan all entries" refactor breaks deliberately), ?? 'item' fallback for malformed entries, default-branch fallback for unknown literals, and the exact 5-field envelope shape {summary, addedCount, updatedCount, removedCount, entries} with no extra keys.
  • packages/agent/src/utils/__tests__/github-app.utils.spec.ts (30 tests, #662) — security-critical 105-LOC utility. createGitHubAppJwt 3-part dotted JWT shape, documented RS256 header {alg,typ}, payload iat = now-60 / exp = now+9*60 / iss = appId (seconds, fake-timer pinned), signature actually RSA-SHA256-verifiable against the matching public key (round-trip via crypto.createVerify confirms the sign actually works — not just that the function returns a string), appId forwarded verbatim into iss (numeric-string + leading-zero preservation pinned), malformed key surfaces underlying Node crypto error rather than being swallowed. createGitHubAppHeaders / createGitHubOAuthHeaders exact 4-header shape (Accept/Authorization/User-Agent/X-GitHub-Api-Version: '2022-11-28'), token forwarded verbatim into Authorization, fresh object per call. requestGitHubAppInstallationAccessTokenDetails POST to /app/installations/<id>/access_tokens with App-JWT headers, {token, expiresAt} envelope on 200, expires_at missing/null coerced to expiresAt: null (?? null fallback), non-OK throws "Failed to create GitHub App installation token: <status> <statusText>", OK-but-missing-token throws "did not include a token", OK-but-empty-string-token throws via the same falsy guard, fetch rejection propagates verbatim. requestGitHubAppInstallationAccessToken thin wrapper returning data.token only; rejection propagates from the underlying details call. verifyGitHubWebhookSignature — security-critical: returns true on canonical sha256=<hex> matching secret+body; returns false on undefined / empty-string header, missing/wrong scheme prefix (sha1=, no scheme, SHA256= case-mismatch — strict equality, NOT case-insensitive), wrong-length signature (pinning the explicit length-guard that runs before timingSafeEqual to avoid the RangeError it throws on mismatched-length inputs — without this guard a malformed/truncated header would crash instead of returning false), same-length-but-wrong-bytes (constant-time compare actually rejects), wrong secret (verifies the HMAC actually depends on the secret), tampered body (verifies the HMAC actually depends on body); also pins the empty-body happy path.
  • packages/agent/src/pipeline/__tests__/pipeline-result.validator.spec.ts (53 tests, #663) — type-checker for pipeline plugin outputs. Every plugin returning a PipelineResult is gated through validatePipelineResult (or the throwing variant) before downstream consumers trust the shape. Pins the non-object short-circuit (null/undefined/string/number/boolean → SINGLE error "Result must be an object", no other errors leak, result field omitted), happy path (same object reference forwarded as result), top-level scalar typeof checks (success / stepsCompleted / totalSteps / duration — zero valid, negatives accepted, missing rejected), duration treated as required despite being JSDoc'd as "Optional fields" (the doc-vs-impl mismatch is now observable), outputs object cascading-error guard (when outputs is null/non-object, the per-array child errors are NOT also emitted — single-error wins), each of items/categories/tags/collections/brands rejected when missing or non-array, state (optional) three-way branch (missing → pass; explicitly-null → "Invalid state" + child checks skipped; non-object scalar → "Invalid state"; valid object → child fields validated), error field accepts string OR Error instance (instanceof branch) but rejects plain objects/numbers, failedStep typeof-string only, error accumulation order (top-level: success → outputs.items → stepsCompleted → totalSteps → duration; outputs children: items → categories → tags → collections → brands), validatePipelineResultOrThrow returns same reference passthrough on success, throws an Error instance (not a string — caller code uses instanceof), "Invalid pipeline result: <errors>" prefix when no pluginId, "Invalid pipeline result from plugin '<id>': <errors>" with pluginId (single-quoted), empty-string pluginId treated as no-plugin (the pluginId ? ... falsy branch — a switch to pluginId !== undefined would break this assertion deliberately), multiple errors joined with "; ".
  • packages/plugins/gemini/src/__tests__/taxonomy-watcher.spec.ts (flake fix, #660) — the "should serialize taxonomy sync for rapid successive writes" test issued two concurrent writeFile calls via Promise.all and then asserted a specific order on the resulting _meta/{categories,tags}.json arrays. fs.watch event ordering is non-deterministic for concurrent writes, so the second write occasionally surfaced first on CI — blocking PR #658 and any subsequent PR that re-runs the gemini-plugin test suite. Switched to set-membership assertions (toHaveLength + expect.arrayContaining); serialization (no entries lost / no duplicates), not insertion order, is the invariant this particular test cares about.

2026-05-09 — packages/agent tiny-utility coverage sweep (+37 tests across 3 new specs, scheduled-task platform-tests-and-docs cycle, #658)

Closes three previously-uncovered small files in packages/agent/src/ — each a contracts-only / pure-function surface that fell through prior sweeps because the consumer files (activity-log.service.ts / comparison-writer.ts / plugin-operations.service.ts / generator-form-schema.service.ts) cover them only indirectly. The three new spec files:

  • packages/agent/src/activity-log/activity-log-analytics-dispatcher.spec.ts (7 tests) — pins the ACTIVITY_LOG_ANALYTICS_DISPATCHER DI token (string literal 'ACTIVITY_LOG_ANALYTICS_DISPATCHER' — NOT a Symbol(...), deliberately, so cross-module @Inject(ACTIVITY_LOG_ANALYTICS_DISPATCHER) bindings in apps/api/src/activity-log/jitsu.service.ts resolve to the same key as the agent-package consumer; pinned via a typeof === 'string' assertion + literal-value match + truthy / non-empty-length / re-import-singleton-identity check), the ActivityLogAnalyticsDispatcher interface contract via runtime mock impl (forwards activity verbatim, returns Promise<void>, rejection propagates — error handling lives at the calling site, not in the interface), and the barrel re-export from activity-log/index.ts.
  • packages/agent/src/comparison-generator/comparison/prompt-keys.spec.ts (8 tests) — pins the three Langfuse-facing prompt keys consumed by comparison-writer.ts:373/392/418: STRUCTURE === 'comparison.structure', MARKDOWN === 'comparison.markdown', EXTENDED_ANALYSIS === 'comparison.extended-analysis'. Adds shape contracts: exactly-three-keys regression guard against silent additions, ^comparison\.[a-z][a-z0-9-]*$ dotted-namespace regex, value-uniqueness so two keys cannot accidentally point at the same Langfuse prompt, as const JSON-roundtrip equivalence so a future swap to a let non-const map (which would lose the literal-type narrowing) breaks loudly, and a barrel re-export pin under the deliberately-renamed alias COMPARISON_PROMPT_KEYS (with an explicit not.toBeDefined on the un-prefixed name so a future "drop the rename" refactor breaks loudly — the rename exists because sibling generators may also ship their own PROMPT_KEYS and the un-prefixed name would clash at the shared barrel level).
  • packages/agent/src/plugins/utils/__tests__/plugin-model-settings.utils.spec.ts (22 tests) — covers buildProviderModelSummaries (consumed by PluginOperationsService + GeneratorFormSchemaService to surface a per-provider list of currently-selected model values to the UI). Five rule groups pinned: (1) schema-without-model-fields short-circuit (undefined schema / no properties / no property has x-widget:'model-select' / all model fields resolved to empty-string OR whitespace-only values — all four → undefined, NOT [], because the consumer treats undefined as "no models to surface"); (2) happy-path summary shape (returns {key, label, value, source, isWorkOverride}isWorkOverride === source === 'work' matrix across ALL 5 documented SettingSource literals (default/env/admin/work/user), title || key fallback (with empty-string title falsy gotcha pinned — pinned so a future swap to title ?? key (which would preserve '') is a deliberate change)); (3) value normalisation (String.prototype.trim applied, post-trim empty-string skip, non-string number/null/undefined skip, missing-from-resolved-map skip, resolved arg can be undefined (optional-chain resolved?.[key] is the only thing keeping that path alive), setting.source undefined coercion when malformed entry has no source field); (4) dedup by trimmed value (collapses two fields resolved to same model into one summary with first-by-sort-order winning, case-sensitive distinction ('gpt-4o' vs 'GPT-4o' are distinct because the dedup is Set<string>-based), leading/trailing whitespace does NOT bypass dedup because dedup runs on the post-trim value); (5) MODEL_FIELD_ORDER sort — the documented order defaultModel → simpleModel → mediumModel → complexModel → model is enforced via REVERSE-registered properties (proves the sort is doing the work, not just preserving insertion order), unrecognised keys pushed to end (after the documented five), both-unrecognised → stable insertion-order preservation via return 0 from the sort comparator. Plus a non-model-field filtering test confirming x-widget:'password' and no-x-widget siblings are ignored.

Total agent-package suite: 3331 → 3368 tests across 159 → 162 suites, all green (full run on the branch verified locally before push). Spec coverage on the four uncovered tiny-utility files in packages/agent/src/ is now closed (74 LOC — the tasks dispatcher tokens are already covered via tasks.spec.ts, and works-config-sync.listener.ts is already covered via works-config/__tests__/works-config-sync.listener.spec.ts — both confirmed by direct inspection during this sweep).


2026-05-09 — packages/agent database.config direct coverage (+28 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #657)

Closes the per-file zero-coverage gap on packages/agent/src/database/database.config.ts (201 LOC) — the TypeORM registerAs('database', ...) factory that resolves the runtime database driver, host, credentials, and SSL settings. The new file is packages/agent/src/database/database.config.spec.ts and pins:

  • ENTITIES list (1 test) — shape invariants pinned (>20 entries, all typeof === 'function', no duplicate registrations) without coupling to specific entity-class identities so a future entity addition is automatically green; pinning the duplicate-free invariant catches the easy "accidentally added Work twice" mistake.
  • SQLite branch (12 tests) — test environment + no DATABASE_PATH:memory:; explicit DATABASE_PATH honoured literally; CLI app type composes ~/.ever-works/ever-works.db via path.join(os.homedir(), '.ever-works', 'ever-works.db'); API + development + DATABASE_IN_MEMORY=falsetmpdir/ever-works-api.db; API + development + DATABASE_IN_MEMORY=true:memory:; falsy getEnvironment() falls back to 'development' semantics (the || 'development' branch); falsy getAppType() falls back to 'api' semantics (the || 'api' branch); sqlite and sqlite3 type aliases both coerced to better-sqlite3; mkdir only fires when parent dir doesn't exist; :memory: AND every other :-prefixed path skips both existsSync and mkdirSync (defence beyond the literal :memory: check via the database.startsWith(':') clause).
  • SSL mode (4 tests)sslMode=truegetTlsOptions(true, dbCaCert) call + ssl field attached; sslMode=false → no getTlsOptions call AND no ssl field; autoMigrate=falsesynchronize: false; loggingEnabled=true propagates.
  • DATABASE_URL branch (3 tests) — when set, parseDatabaseUrl(url) is invoked and BOTH url (the raw string) AND database (the parsed name) are forwarded into the TypeORM options; null parser return → database: undefined passthrough; URL beats type detection (works for mysql://... too).
  • PostgreSQL host config (3 tests) — documented localhost / 5432 / postgres / "" / ever_works defaults applied when env getters return undefined; every default overrideable via getHost/getPort/getUsername/getPassword/getDatabaseName; parseInt(port) accepts trailing-non-digits ('5433x'5433).
  • MySQL/MariaDB host config (3 tests)mariadb type alias normalised to mysql driver in TypeORM options; documented localhost / 3306 / root / "" / ever_works defaults; full override matrix.
  • Unknown-type fallback (1 test) — any unrecognised DATABASE_TYPE (e.g. 'cassandra') silently falls back to better-sqlite3 :memory: rather than throwing.
  • getDatabaseConfig wrapper (1 test) — proxies to the registered factory and returns a TypeORM-compatible options object.

Mocks the entity barrels ('../entities', '../entities/cache.entity', '../plugins/entities', '../account-transfer/entities/user-sync-config.entity') at module scope to empty class shells so the TypeORM CJS init never loads — sidesteps the known path-scurry initialization bug under Jest (Cannot read properties of undefined (reading 'native')). The @src/config module + fs + ./utils (TLS helper + URL parser) are all mocked as well so the suite never touches real env vars or filesystem.

Total agent-package suite: 3303 → 3331 tests across 158 → 159 suites, all green.


2026-05-09 — packages/agent sanitize.util direct coverage (+54 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #654)

Closes the per-file zero-coverage gap on the security-critical packages/agent/src/utils/sanitize.util.ts (213 LOC) — the most heavily-imported utility in the agent package. The sanitiser is consumed by every DTO @Transform decorator (truncating user-supplied name/description/prompt BEFORE class-validator's @MaxLength runs, which is the documented contract that pins the silent-truncate behaviour throughout the agent-package DTO suite). The new file is packages/agent/src/utils/__tests__/sanitize.util.spec.ts and pins:

  • sanitizeText (16 tests) — three falsy-input branches (undefined/null/'') all → '' empty-string short-circuit; default-options matrix (control-strip preserves \n/\r/\t because they're handled separately by removeNewlines/collapseSpaces; control-byte regex strips 0x00-0x08 + 0x0B + 0x0C + 0x0E-0x1F + 0x7F; newline → space; multi-space → single; trim); every option toggle independently turned off and verified (removeNewlines:false preserves \n, collapseSpaces:false preserves runs, trim:false preserves padding, removeControlChars:false preserves \x00); partial-options-merge with DEFAULT_OPTIONS (a custom maxLength:100 does NOT unset the default trim/collapse/etc.); maxLength truncate-then-trim (mid-word cut, trailing-space cut → re-trimmed); if (opts.maxLength && ...) falsy-zero gotcha (maxLength:0 skips truncation entirely, NOT zero-length output); documented operation order pinned via a single end-to-end test (control-strip → newline-replace → collapse → trim → maxLength).
  • sanitizeDescription (4 tests) — default 500-char cap; custom maxLength override; full strip+collapse pass via the underlying sanitizeText; falsy-input → ''.
  • sanitizeName (3 tests) — default 100-char cap; custom maxLength override; same strip+collapse shape as description.
  • sanitizePrompt (6 tests) — default 5000-char cap; CRITICAL contract pin: newlines AND multi-space runs are PRESERVED (because prompts can be multi-line and indentation-sensitive); only trim + control-strip + maxLength apply; control bytes (incl. 0x07 BEL) still stripped.
  • sanitizeObject (9 tests)null/undefined short-circuit return; top-level string-field sanitisation; non-mutation guarantee (input object is NOT modified, returned reference is distinct); recursive walk into nested objects; array-element walk (string elements sanitised, object elements recursed, primitives forwarded verbatim); options forwarded into the recursive call; value && typeof value === 'object' null-guard prevents infinite-recurse-on-null crash.
  • sanitizeStringTransform (2 tests) — class-transformer adapter for generic strings; non-string input forwarded verbatim (the cast to string is type-level only, NOT a runtime coercion).
  • sanitizeDescriptionTransform (3 tests) — class-transformer adapter for description fields; default 500-char cap; non-string forwarded verbatim.
  • sanitizeStringArray (4 tests)undefined/null/non-array/empty-array → [] short-circuit; per-entry trim via sanitizeText then post-filter drop of empty strings (so a whitespace-only input collapses out); internal-whitespace collapse; survivor-order preservation.

Total agent-package suite: 3249 → 3303 tests across 157 → 158 suites, all green.


2026-05-09 — packages/agent database helpers + import enrichment-prompt utils (+64 tests across 3 new specs, scheduled-task platform-tests-and-docs cycle, [PR pending])

Closes three previously-uncovered tiny utility files in packages/agent/src/. Each one is a pure-function helper without per-file co-located coverage; together they account for 50 + 137 + 92 = 279 LOC of zero-coverage agent-package source. Three new spec files:

  • packages/agent/src/database/utils/helper.spec.ts (16 tests) — getTlsOptions four-branch matrix (dbSslMode=false → undefined w/o console.error even when a cert is provided; true + undefined cert → 'DATABASE_CA_CERT is not defined. TLS options cannot be configured.' console.error + undefined return; true + empty-string cert → same console.error path (empty string is falsy); valid base64 → {rejectUnauthorized:true, ca:<decoded>} w/o console.error, including a multi-line PEM round-trip; Buffer.from rejection swallowed with 'Error decoding DATABASE_CA_CERT:' + error.message console.error and undefined return — pinned via a jest.spyOn(Buffer, 'from') that throws). parseDatabaseUrl happy paths (full PostgreSQL URL → {protocol, username, password, host, port, database, searchParams} envelope; URL without explicit port → port: undefined; MySQL URL with empty password; multiple &-joined search params; trailing-slash path → database: ''; protocol trailing-colon strip), invalid-URL null (both invalid-string and empty-string), and the URL-encoded-credentials passthrough (us%40er / p%40ss preserved verbatim — caller is responsible for decoding, pinned so a future decodeURIComponent wrap is a deliberate change).
  • packages/agent/src/database/database-config.factory.spec.ts (27 tests) — createDatabaseModuleWithEnv (env-write happy path + last-write-wins overwrite + empty-map still returns DatabaseModule reference); DatabaseConfigurations.cli (APP_TYPE=cli + DATABASE_TYPE=sqlite); DatabaseConfigurations.apiDevelopment (APP_TYPE=api + sqlite + DATABASE_IN_MEMORY=true + DATABASE_LOGGING=true); DatabaseConfigurations.apiProduction (no-arg → 4 env vars, no DATABASE_PATH; explicit path → DATABASE_PATH set; empty-string path → not-set via the &&-spread truthy gate); DatabaseConfigurations.test (NODE_ENV=test + sqlite + logging-off, deliberately does NOT set DATABASE_IN_MEMORY so the agent default kicks in); DatabaseConfigurations.postgres (url branch sets DATABASE_URL and explicitly does NOT also set per-field env vars; logging-omitted → coerced to 'false'; per-field defaults localhost/5432/postgres/''/ever_works when no options; per-field overrides land via String() on numbers; port:0'5432' documenting the ||-not-?? semantics so a future ?? widening is a deliberate API change); DatabaseConfigurations.mysql (mirror set: url branch with no per-field bleed; logging-off default; per-field defaults localhost/3306/root/''/ever_works; per-field overrides; port:0'3306'); a cross-configuration contract it.each table pinning that all six configurations return the same DatabaseModule reference. The spec mocks './database.module' to a sentinel object so the suite does NOT pull TypeORM/@nestjs/typeorm into the JIT and does NOT exercise any module-init side-effect — the factory contract is purely (a) mutate process.env and (b) return the static DatabaseModule reference. Every spec snapshots/restores 12 tracked env vars in beforeEach/afterEach so the suite leaves no global state behind.
  • packages/agent/src/import/enrichment-prompt.utils.spec.ts (21 tests) — buildImportGenerationDto envelope (returns a CreateItemsGeneratorDto instance, NOT a plain object); name fallback chain (work.name truthy wins; undefinedwork.slug; nullwork.slug (?? not || semantics); empty-string '' → preserved as '' since ?? does not coerce empty strings — pinned so a future || switch is deliberate); hardcoded generation_method: GenerationMethod.CREATE_UPDATE and website_repository_creation_method: WebsiteRepositoryCreationMethod.CREATE_USING_TEMPLATE; update_with_pull_request default-false + explicit-true + explicit-false (default-arg parameter destructuring contract); model verbatim passthrough (undefined or string); pluginConfig literal triple {target_items: 500, max_pages_to_process: 1000, capture_screenshots: true}; per-call freshness (no shared-mutable-state between two consecutive invocations — both providers and pluginConfig are fresh objects per call). Providers section: default {pipeline: 'agent-pipeline'} when no providers passed; explicit pipeline override preserved; passing other keys with no pipeline → default-pipeline merged via ?? while sibling keys flow through; full multi-key passthrough preserves search/extract_content/ai. Prompt section: source URL embedded in opening line; default expansionFactor: 2.5Math.round(100/2.5) = 40'at most 40%'; custom expansionFactor: 425%; non-integer expansionFactor: 3Math.round(100/3) = 33'33%'; all four documented step headers present in document order via indexOf ordering check (## Step 1 — Process source links## Step 2 — Discover more items## Step 3 — Enrich descriptions## Step 4 — Build original taxonomy); legal-safety guidance present ('Do NOT copy descriptions or metadata from the source' + 'legally problematic'); closing 'Do not stop early' directive ends with 'exhausted.'; original-taxonomy 30%-cap directive verbatim. Mocks none — the function is a pure builder; assertions are direct on the returned DTO.

Total agent-package suite: 3185 → 3249 tests across 154 → 157 suites, all green (full run on the branch verified locally before push).


2026-05-09 — packages/agent NestJS-module wiring sweep (+50 tests across 10 new specs, scheduled-task platform-tests-and-docs cycle, #651)

Closes the agent-package NestJS-module zero-coverage gap entirely — every *.module.ts under packages/agent/src/ now has a co-located spec. The 10 new specs follow the established Reflect.getMetadata pattern from markdown-generator.module.spec.ts / account-transfer.module.spec.ts / subscriptions.module.spec.ts: each pins the providers/exports/imports shape with a documented per-module length invariant so a future silent extra-import is a deliberate change, plus barrel re-exports where present.

  • packages/agent/src/work-operations/work-operations.module.spec.ts (5 tests) — WorkOperationsService provider; exports includes BOTH the service AND DatabaseModule (re-export pattern pinned so a future "drop the re-export" refactor is a deliberate change); DatabaseModule import; barrel re-exports both runtime symbols.
  • packages/agent/src/notifications/notifications.module.spec.ts (5 tests) — NotificationService provider + export, DatabaseModule import; barrel re-exports both runtime symbols.
  • packages/agent/src/activity-log/activity-log.module.spec.ts (5 tests) — ActivityLogService provider + export, DatabaseModule import; barrel re-exports both runtime symbols.
  • packages/agent/src/community-pr/community-pr.module.spec.ts (5 tests) — CommunityPrProcessorService AND DistributedTaskLockService providers (the lock is a peer dep provided locally because the cache module isn't imported globally); CommunityPrProcessorService exported but the lock is NOT (pinned via not.toContain); DatabaseModule + FacadesModule imports (length-2 pin); barrel re-exports the processor + module.
  • packages/agent/src/template-catalog/template-catalog.module.spec.ts (5 tests) — TemplateCatalogService provider + export; DatabaseModule + FacadesModule imports (length-2 pin); barrel re-exports both runtime symbols.
  • packages/agent/src/comparison-generator/comparison-generator.module.spec.ts (5 tests) — ComparisonGenerationService provider + export; DatabaseModule + FacadesModule imports (length-2 pin); barrel re-exports both runtime symbols.
  • packages/agent/src/pipeline/pipeline.module.spec.ts (5 tests) — all 5 pipeline services (PipelineBuilderService + StepPipelineExecutorService + FullPipelineExecutorService + PipelineOrchestratorService + PipelineFacadeService) as providers AND exports (length-5 pin on both); FacadesModule import only (length-1 pin) with the source-comment-documented exclusion of PluginsModule (registered globally via forRoot) explicitly pinned via not.toContain('PluginsModule').
  • packages/agent/src/import/import.module.spec.ts (4 tests) — three providers (SourceRepoAnalyzerService + ImportExecutorService + WorksConfigService) all exported (length-3 pin); the documented 4 imports (FacadesModule + DataGeneratorModule + MarkdownGeneratorModule + WebsiteGeneratorModule, length-4 pin). The two service classes are mocked module-scope to class shells because their real implementations transitively pull in p-map (ESM-only) via the generator stack.
  • packages/agent/src/generators/website-generator/website-generator.module.spec.ts (5 tests) — all 4 services (WebsiteGeneratorService + WebsiteUpdateService + BranchSyncService + WebsiteTemplateResolverService) as providers AND exports (length-4 pin on both); FacadesModule + DatabaseModule imports (length-2 pin); barrel re-exports the module + all 4 service runtime classes.
  • packages/agent/src/generators/data-generator/data-generator.module.spec.ts (5 tests) — three providers (DataGeneratorService + WorksConfigService + WorksConfigWriterService, length-3 pin); ONLY DataGeneratorService is exported (the two WorksConfig* helpers stay internal, pinned via not.toContain so a future "expose them" refactor is a deliberate change, length-1 pin on exports); the documented 4 imports (FacadesModule + PipelineModule + DatabaseModule + WorkOperationsModule, length-4 pin); barrel re-exports DataGeneratorModule + the runtime classes DataRepository + GenerationLogCollector. data-generator.service is mocked module-scope (it transitively imports p-map).

Total agent-package suite: 3134 → 3249 tests across 144 → 157 suites, all green. The +115 tests / +13 suites delta is bigger than the 50/10 figures above because (a) the work-operations / notifications / activity-log / community-pr / template-catalog / comparison-generator / pipeline / import / website-generator / data-generator module specs land alongside this hour's +26-test WebsiteUpdateService row already on develop, and (b) running the full suite picks up the account-transfer.module.spec.ts etc. that already shipped earlier this day.


2026-05-09 — packages/agent MarkdownGeneratorService + module coverage (+54 tests across 2 new specs, scheduled-task platform-tests-and-docs cycle, #648)

Closes the markdown-generator orchestrator zero-coverage gap left over from the prior helper-class sweep (#646). The two new files cover the 508-LOC markdown-generator.service.ts directly plus the small markdown-generator.module.ts wiring barrel.

  • packages/agent/src/generators/markdown-generator/markdown-generator.service.spec.ts (49 tests) — initialize repository setup (createRepository payload + private flag + organization-passthrough vs personal-account fallback + assertCreatedRepositoryTarget mismatch rethrow + cloneFreshRepository retry-safe markdown clone path NOT cloneOrPull + cloneOrPull data-repo coordinates); generation_method branches (RECREATE switches to default branch then resetFiles + canCreatePR=false; RECREATE with null getMainBranch skips switch; RECREATE swallows getMainBranch rejection (treats as null); RECREATE swallows switchBranch rejection; CREATE_UPDATE with pr_update.branch switches BOTH markdown + data repos with force=true; CREATE_UPDATE PR-branch-switch failure clears canCreatePR; CREATE_UPDATE without pr_update skips both branches); items + categories + tags (per-slug getMarkdown truthy/falsy → writeDetails only on truthy + the slug enters the "details exists" Set; getItem=null continues iteration without group entry; per-item getItem-rejection warn-and-skip with surrounding items still processed; non-Error throw value coerced via String() in the warn message; array-shaped category normalisation + ad-hoc category registration into the loaded map; populate<T> tags-passthrough hydrates strings into cached {id,name} objects + reuses cached object across items + cached value wins over inline-passed object with same id; options.remove_details removes per-slug detail files (and removes them from the markdowns Set); LICENSE.md only written when getLicense returns truthy, skipped on null); README + commit/push/PR (config.content_table=trueenableToC() else skip; addAll → commit('sync README.md', committer) → push order pinned via invocationCallOrder; writeReadme consumes ReadmeBuilder.build(); CREATE_UPDATE + canCreatePR + defaultBranch opens PR with full positional shape and persists lastPullRequest.main = {branch, title, body, number, url}; createPullRequest rejection swallowed so updateLastPullRequest is NOT called and the service does NOT throw; defaultBranch=null short-circuits PR opening even when canCreatePR=true; inner-step rejection (e.g. addAll) re-throws verbatim); cancellation (already-aborted signal short-circuits BEFORE createRepository is invoked, signal.reason instanceof Error is preserved as the throw, createGenerationCancelledError() envelope pinned with name='AbortError'); removeItemDetail (uses cloneOrPull not cloneFreshRepository, removeDetails(slug) always called even on switchBranch rejection, branch arg gates the switchBranch call, switch-before-remove order pinned via invocationCallOrder, no switch when branch arg omitted); removeRepository (deletes remote then cleanups the local dir derived from getLocalDir(provider, owner, repo), delete-before-cleanup order pinned, rethrows on deleteRepository rejection AND skips cleanup); cleanup (returns the MarkdownRepository.cleanup() result on the dir from getLocalDir); private sortCategoriesByPriority (featured-bearing categories beat non-featured regardless of priority; same-featured-bucket sorts by ascending priority when both have priority; A-has-priority/B-has-no-priority → A wins; both-no-priority falls back to alphabetical name; both-no-priority + different featured counts → higher count wins) and the per-category internal item sort (featured-true precedes featured-false, equal-featured items sort by ascending order then alphabetical name); hasDetails:true is forwarded to addItem only when the markdowns Set contains the item slug; private populate<T> cached-object branch via the tags-loading pipeline (cached entry wins over freshly-passed object with same id; freshly-built {id,name} registered for unseen string-tags and reused on subsequent items). node:fs/promises is mocked module-scope (only readdir is needed since the service never touches mkdir/rm/writeFile directly — those go through MarkdownRepository); cloneFreshRepository is mocked module-scope; MarkdownRepository.prototype methods + DataRepository.create are spied per-test so the suite never touches the real filesystem. github-slugger is also mocked module-scope (it ships ESM-only and Jest's CJS pipeline cannot import it via the readme-builder transitive import path).
  • packages/agent/src/generators/markdown-generator/markdown-generator.module.spec.ts (5 tests) — Reflect.getMetadata-based pinning of the @Module() shape: MarkdownGeneratorService declared as a provider AND exported, the documented 4-import shape (DataGeneratorModule/FacadesModule/DatabaseModule/WorkOperationsModule) preserved with a length pin so a future silent extra-import is a deliberate change; barrel re-exports MarkdownGeneratorModule + MarkdownGeneratorService + the runtime classes MarkdownRepository + ReadmeBuilder. The spec mocks @src/generators/data-generator/data-generator.service to a class shell because the real one transitively imports p-map (ESM-only) which Jest's CJS pipeline cannot load — same pattern as the existing services/__tests__/work-generation.service.spec.ts.

Total agent-package suite: 3080 → 3134 tests across 142 → 144 suites, all green (counted on top of the WebsiteUpdateService row already on develop). The markdown-generator zero-coverage gap (orchestrator + helpers + module) is now fully closed.


2026-05-09 — packages/agent WebsiteUpdateService direct coverage (+26 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #649)

Closes the per-file zero-coverage gap on packages/agent/src/generators/website-generator/website-update.service.ts (400 LOC) — the last public service in the website-generator subdir without a co-located spec. The new file is packages/agent/src/generators/website-generator/website-update.service.spec.ts and pins:

  • updateRepository (7 tests)NotFoundException("Website repository '<owner>/<repo>' does not exist") thrown BEFORE any getLatestCommit/cloneOrPull call when repositoryExists returns false; happy path uses the duplicate method (removeLocalDir(template)cloneOrPull(template, branch)switchBranchreplaceRemote('origin', targetUrl)push({force:true})) and returns {method:'duplicate', message, commitSha, branchSync}; on cloneOrPull rejection in the duplicate path, falls back to the template method (Promise.all clone of both → copyRepositoryFilesaddcommit('Update website from template (<branch>)', committer)push({force:true})); when BOTH methods exhaust, throws Error('All update methods failed. Last error: <templateError.message>') AFTER logger.error('Template update failed: ...'); options.branch override propagates to cloneOrPull.branch AND switchBranch; getLatestCommit returning nullcommitSha:undefined; falsy branchSyncService.syncFromTemplate (e.g. null) → branchSync:undefined.
  • ensureTemplateDefaultBranch (5 tests, exercised via updateRepository) — best-effort: when remote listBranches includes the target branch, calls gitFacade.updateRepository(owner, repo, {defaultBranch: target}, {userId, providerId, workId}); when target is missing, warns Cannot set default branch to '<target>' for <owner>/<repo> because the branch does not exist yet and skips updateRepository; listBranches Error rejection → warns Failed to set default branch for <owner>/<repo>: <error.message>; non-Error rejection coerced to String(error) in the warn copy; even an updateRepository rejection is swallowed (only logged) — the overall WebsiteUpdateService.updateRepository resolves successfully because default-branch synchronisation is downstream of the actual file update and must NEVER block it.
  • syncAllBranchesFromTemplate (2 tests) — thin branchSyncService.syncFromTemplate(work, user) delegate; passes the summary through verbatim AND forwards a null return as-is.
  • checkForUpdate (6 tests)hasValidCredentials:false{updateAvailable:false, branch, error:'Git provider credentials not available'} with NO getLatestCommit call (early return); getLatestCommit:null{updateAvailable:false, branch} (no error field); differing sha → updateAvailable:true with both latestCommit and currentCommit populated; matching sha → updateAvailable:false with both populated; null work.websiteTemplateLastCommitcurrentCommit:undefined via the || undefined coercion; work.websiteTemplateUseBeta=true AND template.betaBranch set → getWebsiteTemplateBranch returns the beta branch and getLatestCommit is called with it.
  • updateFork private branch (3 tests, exercised via reflection) — currently DEAD CODE from updateRepository's perspective (the orchestrator only attempts duplicate→template). Tests pin the existing branches anyway so a future re-wiring is a deliberate change: hasForkRelationship:true → returns true; :false → returns false; cloneOrPull rejection → returns false AND emits logger.error('Fork update failed: <message>').
  • copyRepositoryFiles (2 tests, exercised via the duplicate-fail → template path) — skips .git directories at every recursion level; for each subdirectory entry, calls fs.rm(target, {recursive:true, force:true}) to clear out stale state (catches the rejection if the dir doesn't exist), then fs.mkdir(target, {recursive:true}), then recurses; for files, calls fs.copyFile(source, target). Output paths are normalised to forward slashes in the assertions so the suite passes on both POSIX and Windows (since path.join produces \ on Windows). Even an fs.rm rejection is silently caught — the comment // Work might not exist, which is fine is pinned via a test that triggers it.
  • Contracts (1 test)logger is the standard NestJS Logger keyed to the service name.

node:fs/promises is mocked at module scope so the suite never touches the real filesystem; gitFacade, branchSyncService, and websiteTemplateResolver are plain jest.fn() objects. Total agent-package suite: 3054 → 3080 tests across 141 → 142 suites, all green (counted on top of develop, BEFORE PR #648's MarkdownGeneratorService coverage merges in).


2026-05-09 — packages/agent markdown-generator helper-class coverage (+30 tests across 2 new specs, scheduled-task platform-tests-and-docs cycle, #646)

Closes the markdown-generator helper-class zero-coverage gap. The two helpers in packages/agent/src/generators/markdown-generator/ that previously had no co-located spec — readme-builder.ts (87 LOC) and markdown-repository.ts (66 LOC) — are now both pinned. The 508-LOC markdown-generator.service.ts orchestrator is deferred to a dedicated follow-up.

  • packages/agent/src/generators/markdown-generator/readme-builder.spec.ts (17 tests) — build() envelope shape (header + body + footer separator, multi-line header/footer preserved verbatim); addHeader H1 + double-newline; addSubHeader H2 + ToC entry registration + chainability; addParagraph + addNewLine newline composition; enableToC() opt-in (default-omitted vs explicit-rendered with ## 📑 Table of Contents header), en-US-formatted counts (1234 → "1,234", 0 → "0" not omitted, undefined → omitted entirely), github-slugger duplicate-slug -1/-2/... disambiguation (which forced a per-test jest.isolateModules() reload because the source module instantiates the slugger at top-level and never resets it — the spec pins this current behavior so a future module-level slugger.reset() call would be a deliberate change); addItem line format - [name](url) - description with optional ([Read more](/details/<slug>.md)) suffix, backtick-wrapped tag list joined w/ spaces, BOTH-suffixes ordering (details link THEN tags), missing-tags-or-empty-array no-suffix path; end-to-end document shape (header → ToC → sub-header → items → footer). github-slugger is mocked at module scope (it ships ESM-only and Jest's CJS pipeline cannot import it; the mock is a deterministic lowercase + non-alphanum→'-' + start/end-trim + count++ slugger with the same dedup contract).
  • packages/agent/src/generators/markdown-generator/markdown-repository.spec.ts (13 tests) — cleanup calls fs.rm(dir, {recursive:true, force:true}) once; resetFiles removes every non-allowlisted entry (README.md, ad-hoc files, even the details/ directory itself) then re-creates it via ensureWorksExist, with the standard allowlist preserved (.git, .gitignore, .github, .vscode, .env, .nvmrc) PLUS every dotfile starting with .git (covers .gitattributes, .gitkeep, etc. via the startsWith('.git') branch), sequential await-per-iteration removal order pinned via shared order array, empty-readdir still calls mkdir(details/, recursive:true), readdir-failure short-circuits without calling mkdir or rm; ensureWorksExist calls mkdir(detailsPath, {recursive:true}); writeReadme/writeDetails/writeLicense write dir/README.md/dir/details/<slug>.md/dir/LICENSE.md respectively with 'utf-8' encoding; removeDetails calls fs.rm with {force:true} ONLY (NOT recursive:true — the spec explicitly pins that detail-removal is a file-only operation, not a directory delete); dir is exposed as a public readonly property and the dir → dir/details derivation is pinned via a custom-dir test. node:fs/promises is mocked at module scope so the suite never touches the real filesystem.

Total agent-package suite: 3024 → 3054 tests across 139 → 141 suites, all green.


2026-05-09 — packages/agent BranchSyncService direct coverage (+24 tests across 1 new spec, scheduled-task platform-tests-and-docs cycle, #644)

Closes the per-file zero-coverage gap on packages/agent/src/generators/website-generator/branch-sync.service.ts (327 LOC) — the only public service in the website-generator subdir without a co-located spec (siblings website-generator.service.ts + website-template-resolver.service.ts already had specs). The new file is packages/agent/src/generators/website-generator/branch-sync.service.spec.ts and pins three groups:

  • syncBranch (7 tests) — happy path (cloneOrPull → optional renameBranch when targetBranch !== branchNamegetCloneUrl(providerId, owner, repo)replaceRemote(providerId, dir, 'origin', url)push({dir, force: true}, {userId, providerId, workId})) with fs.rm(tempDir, {recursive:true, force:true}) cleanup running unconditionally in finally; forcePush=false override; providerId/workId undefined-passthrough on every facade call; success message format "Successfully synced branch '<name>'" vs "Successfully synced branch '<name>' (mapped to '<target>')"; error result envelope on post-clone failure still triggers fs.rm cleanup; pre-clone cloneOrPull rejection → tempDir is null so fs.rm is NEVER called; fs.rm rejection is swallowed silently via the trailing .catch(() => {}).
  • syncAllBranches (10 tests) — three-branch template.syncBranches → three sequential clones (driven by fake timers + jest.runAllTimersAsync() to traverse the inter-batch 1000ms setTimeout); branchMapping[stage]='main' → two ops (stage→stage AND stage→main) with the renameBranch call only on the second; branchMapping[stage]='main' AND 'main' is itself in syncBranches → standalone main op skipped because mappedTargets.includes('main') AND !branchMapping['main']; self-mapped branchMapping[main]='main' is NOT skipped (the !branchMapping[branchName] truthy guard saves it); Promise.allSettled rejected branches map to {status:'error', message: reason?.message || 'Unknown error'} with empty-Error fallback to 'Unknown error'; the i + MAX_CONCURRENT_SYNCS < syncOperations.length guard suppresses the trailing inter-batch sleep; cleanupExtraBranches=true invokes gitFacade.listBranches(owner, repo, {userId, providerId}) then deletes every branch NOT in template.syncBranches; listBranches-failure → warn + early return with no deleteBranch calls; per-deleteBranch rejection → warn + loop continues; cleanupExtraBranches=false/omitted → listBranches is never called.
  • syncFromTemplate (5 tests) + contracts (2 tests) — full envelope construction (getRepoOwner('website')/getWebsiteRepo() for target, getWorkOwner(work) for userId, work.resolveCommitter(user) for the committer struct, forcePush:true always, cleanupExtraBranches defaulting to false and respecting an explicit true); beta branch mapping {[betaBranch]: 'main'} only when work.websiteTemplateUseBeta && template.betaBranch (both legs tested — flag set + betaBranch:nullbranchMapping:undefined); outer try/catch on syncAllBranches rejection → null return + logger.error("Failed to sync branches from template: <msg>"); resolveForWork rejection happens BEFORE the try/catch and propagates verbatim (pinned current behaviour so a future widening of the catch is a deliberate change); MAX_CONCURRENT_SYNCS=1 invariant pinned (the source comment documents that parallel syncs corrupt each other because cloneOrPull uses an owner+repo-only deterministic dir).

node:fs/promises is mocked at module scope so the suite never touches the real filesystem; gitFacade and websiteTemplateResolver are plain jest.fn() objects. Total agent-package suite: 3000 → 3024 tests across 138 → 139 suites, all green.


2026-05-09 — packages/agent BaseFacadeService + FacadesModule direct coverage (+75 tests across 2 new specs, scheduled-task platform-tests-and-docs cycle, [PR pending])

Closes the last per-file coverage gap inside packages/agent/src/facades/. Before this sweep the abstract BaseFacadeService (291 LOC), facades.module.ts (53 LOC), and index.ts barrel were exercised only indirectly via the nine concrete-facade specs — meaning the resolution ladder, settings-typing helpers, and FacadeError hierarchy had no targeted unit. Two new files now pin the lot:

  • packages/agent/src/facades/__tests__/base.facade.spec.ts66 tests built on a thin TestFacadeService extends BaseFacadeService that re-exposes every protected helper. Covers:

    • FacadeError hierarchy: 4 tests pinning name="FacadeError"|"NoProviderError"|"ProviderNotFoundError", operation="getPlugin" literal on the two derived classes, capability-templated message strings ("No <cap> provider configured or available", "<cap> provider not found: <id>"), instance-of FacadeError chain, and the deliberate provider:undefined slot on NoProviderError (since the whole point is "no provider").
    • isConfigured: 3 tests — true when ≥1 capability plugin in state="loaded", false on empty list, false on plugins-but-none-loaded; pins the state==="loaded" strict-equal check via an error-state plugin.
    • getAvailableProviders: 3 tests — {id, name, enabled} shape with enabled := state==="loaded", empty-array path, getProviderName fallback chain (providerName → sourceName → plugin.name) flowing through.
    • getActiveProviderName: 3 tests — happy path returns name, null when no provider, facadeOptions.workId/userId forwarded into getDefaultProvider (asserted via the findActiveByCapability call).
    • getDefaultProvider (the work-active branch is the most-branchy method on the class, 5 distinct paths): 7 tests — work-active wins and short-circuits the getByCapability fallback (asserted via not.toHaveBeenCalled()); fall-through when state≠loaded; fall-through when isPluginEnabledForScope rejects; try/catch swallows findActiveByCapability rejection silently (a future "rethrow on db failure" tightening would now break this test); workId === undefined skips the work-active branch entirely; missing workPluginRepository (DI optional) skips the work-active branch entirely; null-when-no-active-AND-no-enabled.
    • getEnabledPlugins: 5 tests — state≠loaded filtered BEFORE the isPluginEnabledForScope call (asserted via call-count = 1), isPluginEnabledForScope=false drops the plugin, manifest.defaultForCapabilities containing the CAPABILITY sorts that plugin first, missing defaultForCapabilities is treated as "not default" (no crash, stable sort), empty input → [] with no isPluginEnabledForScope call.
    • isPluginEnabled passthrough: 1 test — verbatim (pluginId, workId, userId) positional shape into registry.isPluginEnabledForScope.
    • getResolvedSettings: 2 tests — {userId, workId, includeSecrets:true} envelope (pinned: secrets ARE pulled at the facade boundary by design — a future "default to false" flip would change which settings flow into AI providers); {} returned with NO settings-service call when DI omitted.
    • getProviderName: 4 tests — providerName preferred, sourceName fallback, plugin.name fallback, empty-string providerName treated as falsy and falls through to sourceName.
    • getSettingTyped: 6 tests — undefined/null short-circuit (no warn), every primitive happy path, object-vs-array distinguished via Array.isArray (an array MUST NOT match expectedType="object" and a plain object MUST NOT match expectedType="array" — pinned both directions), type-mismatch warns AND returns undefined, missing values do NOT warn (no false-positive log noise).
    • getSettingRequired: 3 tests — happy path, throws "Required setting <key> is missing or has wrong type" on undefined, includes "for plugin <id>" qualifier when pluginId arg supplied.
    • getSettingWithDefault: 6 tests — happy path; default on missing; default on null; default on type mismatch (warns first); the critical false/0/"" falsy-but-defined preservation tests pinned via ?? (a future "use ||" refactor would silently coerce a configured false boolean to true, a configured 0 to a non-zero default, and a configured "" to a non-empty default).
    • findActivePluginForWork: 6 tests — happy path, unregistered → null, registered-but-unloaded → null, no active row → null (no registry.get call), try/catch swallows repo rejection, missing repository (DI optional) → null.
    • resolvePlugin: 9 tests — providerOverride happy path; ProviderNotFoundError when override unknown; ProviderNotFoundError when override registered but capability-mismatched; ProviderNotFoundError when override registered+capability but state≠loaded (and the enable-check is skipped — pinned via not.toHaveBeenCalled()); ProviderNotFoundError when override fails the enable check; no-override + work-active wins (skips the capability scan — pinned); no-override + no-work-active falls through to first-enabled with sort; NoProviderError when no work-active AND no enabled plugins; workId=undefined skips the work-active branch entirely.
    • FacadesModule re-exports sanity: 1 test — BaseFacadeService + the three error classes are reachable via the barrel index (so a future barrel-cleanup that removes them would surface here).
  • packages/agent/src/facades/__tests__/facades.module.spec.ts9 tests pinning the wire-format-stable surface of the agent's FacadesModule. apps/api/src/plugins-capabilities/* and the agent pipeline steps import the same nine facade classes by name, so a silent removal/rename here would change which provider Nest hands consumers. Tests cover: imports contains DatabaseModule by name (pinned by name not constructor identity to avoid coupling to its export path); providers contains all nine facade classes one-to-one with no extras (toHaveLength(9)); exports mirrors providers exactly (since the module body is providers: FACADES, exports: FACADES); a single shared FACADES array (pin via expect(providers).toEqual(exports) so a future split silently making some facades private surfaces here); barrel re-exports each of the nine facade-service classes verbatim (toBe identity, not typeof so a re-export-via-renamed-shim would fail); barrel re-exports the 15 facade-specific error classes (AiFacadeError/SearchFacadeError/…/OAuthNotSupportedError/NoDeployCredentialsError); barrel re-exports the shared BaseFacadeService + FacadeError + NoProviderError + ProviderNotFoundError; an exact-runtime-keys regression guard pinning the 33 documented runtime symbols and explicitly listing the 13 type-only re-exports that MUST NOT appear (DefaultProviderInfo, FacadeOptions, SearchFacadeOptions, FacadeExtractionOptions, FacadeExtractedContent, GitFacadeOptions, GitProviderInfo, FacadeCloneOptions, FacadePushOptions, DataSourceFacadeOptions, DataSourceFacadeResult, EnabledDataSource, DeployFacadeFullOptions) — a future "convert to runtime export" would surface here so the type-vs-runtime contract is deliberate.

Suite passes via pnpm --filter @ever-works/agent test --testPathPattern='facades/__tests__/(base|facades)' in ~22s. Full agent suite 138 suites / 2926 → 3001 tests green.

2026-05-09 — packages/agent WorkGenerationService orchestrators sweep (multi-step pipeline-orchestrator coverage, +46 tests on the existing work-generation.service.spec.ts, scheduled-task platform-tests-and-docs cycle, #639)

Closes the FINAL pipeline-orchestrator coverage gap by adding 7 new top-level describe blocks (one per orchestrator) at the bottom of packages/agent/src/services/__tests__/work-generation.service.spec.ts covering the seven previously-pending multi-step orchestrators exercised via (service as any) cast. Suite passes via pnpm --filter @ever-works/agent test --testPathPattern='services/__tests__/work-generation.service' in ~15s, total 172 tests in this file (126 → 172); full agent suite 136 suites / 2880 → 2926 tests green. Orchestrators covered:

  • ensureProvidersEnabledForWork (6 tests): no-op when providers undefined (pinned: dto.providers is optional, a future "always send" tightening would silently invoke the loop with an empty literal); skips falsy provider entries (undefined / '' / null) without touching pluginRegistry; auto-enables a configured provider via enablePluginForWork w/ the resolved capability via getCapabilityFromUIKey('ai') === 'ai-provider' (NOT the literal uiKey — pinned because a future "ship the uiKey straight through" refactor would mis-tag enable rows in the plugin scope table); removes a disabled provider from the dto AND warns (so pipeline falls back to default — explicit-disable wins, MUST NOT re-enable); swallows enablePluginForWork failures silently (catch block must NOT propagate "already enabled" duplicate-row errors); processes every selectable category (search/screenshot/ai/contentExtractor/pipeline) when set — pinned to all 5 categories from SELECTABLE_PROVIDER_CATEGORIES so a future addition to that constant must show up here.
  • prepareProviders (3 tests): runs ensureProvidersEnabledForWork → validateSelectedProviders → validateFormSchemaPlugins → processFormConfig in order (pinned via shared order: string[] array w/ mockImplementation push — a future swap (e.g. validate before ensure-enabled) would run validation on a mid-mutation dto.providers and could reject providers the auto-enable step would have just deleted); forwards processed config back into the dto (mutates pluginConfig + _processedPluginConfig — a future "return a new dto" refactor would silently pass un-processed config to the data generator); forwards (pipelineProviderId, originalPluginConfig, scopeOptions) positionally to processFormConfig (mis-positioning would leak the dto's other keys into pluginConfig).
  • runInProcessGeneration (3 tests): returns normally when processGeneration succeeds; rethrows HttpException unchanged (status code preserved — a future "wrap everything in BadRequest" flip would collapse all statuses to 400); wraps non-HttpException in BadRequestException w/ {status:'error', slug, message} envelope shape (a future "rethrow original error" flip would let internal stack traces leak through).
  • finalizeGeneration (8 tests): writes GENERATED status + clears step on success (no-error / no-warnings path); writes ERROR status + error message + preserves step (no step: null clear) when error is present (pinned: on the error path, the step the pipeline crashed on stays in place so the UI can surface "failed during X" — the conditional step: null only fires on the success path); writes CANCELLED status + GENERATION_CANCELLED message on cancellation errors (isGenerationCancelledError short-circuit); updates the history entry with terminal status + duration + warnings + stats when history is provided (buildStatsUpdate projection of newItemsCount/updatedItemsCount/totalItemsCount); skips history update when no history provided; finalizes the schedule run with {status:'completed', historyId} on a successful schedule trigger; finalizes with {status:'failed', reason} on a failed schedule trigger; does NOT touch the schedule when triggeredBy=user even with a stray scheduleId (pinned: only schedule-triggered runs report back); does NOT touch the schedule on a schedule trigger with NO scheduleId (defence in depth).
  • executeGenerationPipeline (10 tests): skips markdown + website when initialise returns success=true with zero items and no existing items (pinned: a no-op generation means the website repo has nothing to render, "always run markdown" tightening would crash on the empty data set); runs markdown when newItemsCount>0 AND runs website when hasExistingItems OR newItemsCount>0; runs website when hasExistingItems=true even with zero new/updated items (pinned: re-running website regenerate after a no-op data generation must still rebuild repo with template/markdown changes); throws error.cause when initialise returns success=false with a cause (pinned: error.cause is the original Error from inside the pipeline with a real stack — a future "always synthesise a fresh Error" would lose the original stack); throws fresh Error(message) when no cause; forwards tryResume=true to dataGenerator on schedule trigger (pinned: schedule-triggered generations resume in-progress pipelines, user-triggered always start fresh); forwards tryResume=false on user trigger; forwards generated.prUpdate into markdownGenerator.initialize; swallows "repository not found" website failure into a warning when items are present (pinned by isNonFatalWebsiteGenerationError: a missing website repo on a successful data generation is recoverable — a future "all website failures are fatal" tightening would crash generation after the data was already pushed); rethrows non-recoverable website failures (e.g. "Permission denied"); aborts the pipeline before the markdown step when the abort signal is already aborted (pinned: throwIfGenerationCancelled fires AFTER data generation but BEFORE markdown — a future "skip the mid-pipeline cancel check" refactor would let the markdown step run on cancelled work).
  • dispatchGenerationTask (7 tests): marks the work GENERATING immediately for instant UI feedback — does NOT wait for Trigger.dev (pinned: a future "wait for trigger ack first" refactor would leave UI on prior status until dispatch round-trips); persists triggerRunId on the history entry when dispatcher returns a run id; builds the WorkGenerationPayload with mode + workId + userId + dto + historyId + triggerSource + scheduleId + ISO historyStartedAt; falls back to history.createdAt when history.startedAt is missing (chain ?? createdAt ?? new Date()); falls back to in-process generation (AWAIT) when dispatcher unavailable on schedule trigger (pinned: schedule-triggered fallbacks AWAIT processGeneration so cron loop runs sequentially — concurrent fallback runs would explode resource usage); falls back to FIRE-AND-FORGET on user trigger when no dispatcher wired (pinned: the API controller has already returned to the user with GENERATING status — awaiting here would block the request for the duration of the entire pipeline); falls back to fire-and-forget when dispatcher returns null (pinned: a dispatcher that resolved to null e.g. trigger disabled at runtime is treated identically to "no dispatcher wired").
  • processGeneration (6 tests): emits WorkGenerationCompletedEvent on the happy path with the FRESHLY-REFETCHED work payload (pinned: listeners see the GENERATED status, not stale GENERATING — a future "emit pre-pipeline work" refactor would replay stale state); falls back to original work when refresh returns null (defence in depth); returns silently on cancellation error — no notification, no rethrow, no error log (pinned: cancellation is intentional, not exceptional — a future "treat cancellation as failure" flip would page the user with a generic "generation failed"); routes the error through handleErrorNotification on a real failure AND logs the error; rethrows HttpException after notifying (so the API layer can map the status code); runs finalizeGeneration even when the pipeline throws (terminal-state guarantee — a future "skip finalize on schedule failures" refactor would leave the work stuck in GENERATING); clears the abort controller from the in-memory map after processing (pinned: leaving stale controllers around would leak memory on a long-lived process AND would let cancelGeneration for a subsequent run fire on the wrong signal).

Closes the WorkGenerationService private-orchestrator coverage gap — every private orchestrator and helper in packages/agent/src/services/work-generation.service.ts now has unit-test pins. The previously-deferred multi-step orchestrators (processGeneration / executeGenerationPipeline / finalizeGeneration / runInProcessGeneration / prepareProviders / ensureProvidersEnabledForWork / dispatchGenerationTask) are now exercised end-to-end via mocks for dataGenerator.initialize (returns {success, stats, warnings, prUpdate, hasExistingItems, error}), markdownGenerator.initialize (signal-aware), websiteGenerator.initialize (signal-aware), workScheduleService.finalizeScheduleRun (schedule terminal-state guarantee), and eventEmitter.emit (event-fan-out invariant). Merged via PR #639.

2026-05-09 — packages/agent WorkGenerationService helpers follow-up sweep (private-helper coverage, +31 tests on the existing work-generation.service.spec.ts, scheduled-task platform-tests-and-docs cycle, #637)

Closes the bulk of the pipeline-orchestrator helper gap left open by the focused-first sweep #6 (PR #634). Adds 7 new top-level describe blocks at the bottom of packages/agent/src/services/__tests__/work-generation.service.spec.ts covering the simpler / pure private helpers exercised via (service as any) cast (TypeScript-private is compile-time only; at runtime the methods are accessible). Suite passes via pnpm --filter @ever-works/agent test --testPathPattern='services/__tests__/work-generation.service' in ~15s, total 126 tests in this file (95 → 126); full agent suite 136 suites / 2849 → 2880 tests green. Helpers covered:

  • resolveGenerationFinalStatus (3 tests): CANCELLED branch wins over the generic-error branch (pinned because a future swap would record real ERROR for user-initiated cancels and trigger downstream notifications that should not fire); ERROR for any non-cancelled truthy value incl. non-Error truthies (string, plain object — pinned because callers pass unknown from generic catch blocks); GENERATED for falsy values (null/undefined/0/'').
  • resolveGenerationErrorMessage (3 tests): undefined for falsy errors; documented GENERATION_CANCELLED constant (NOT Error.message) for cancelled errors so the UI can show "Generation cancelled." without translating internal wording; falls through to normalizeGeneratorError for everything else, pinning the documented "Repository not found." copy substitution.
  • buildScheduleRunOutcome (5 tests): {status:'completed', historyId} envelope on success; explicit-undefined historyId field when not provided (pinned so a future "use spread-when-defined" change is deliberate); {status:'failed', reason: GENERATION_CANCELLED} on cancellation; {status:'failed', reason: <normalized>} on generic error; historyId is dropped on the failed branch (pinned: a downstream consumer if ('historyId' in outcome) must NOT misread a failed outcome as a resumable run).
  • isNonFatalWebsiteGenerationError (4 tests): false when zero items were generated (pinned: brand-new-work-with-no-items website failure is fatal — masking it would hide real config issues); false for non-"repository not found" errors; true when items exist AND the error matches "repository not found" (data-side progress preserved while website repo is auto-created later); case-insensitive match via normalizeGeneratorError's .toLowerCase().
  • markGenerationStarted (3 tests): work-side updates run via Promise.all (history untouched when undefined — pinned: legacy direct-generation path); also marks the history entry GENERATING with startedAt when history is provided; rejection from either of the two parallel work-side updates rejects the whole call (so the caller's try/catch can record the failure).
  • finalizeCancelledGeneration (7 tests): writes CANCELLED status + GENERATION_CANCELLED error and clears step; updates history with status/finishedAt/duration when provided; falls back to finishedAt for startedAt when history is partial — produces 0-second duration, NOT NaN (pinned: a future "trust startedAt unconditionally" refactor would write NaN into the duration column and break the analytics-summary aggregation); finalizes the schedule as {status:'failed', reason: GENERATION_CANCELLED} when scheduleId is set (cadence's failure counter advances → pause-on-N-failures still works); does NOT touch the schedule when scheduleId is null/undefined; emits WorkGenerationCompletedEvent with the FRESHLY-REFETCHED work (pinned via distinct-instance assertion — listeners see the CANCELLED status, not stale GENERATING); skips the event when the refreshed work is missing (deleted-mid-cancel guard prevents the listener queue from crashing on event.work.X).
  • handleErrorNotification (6 tests): no-op when NotificationService is omitted from DI (pinned: agent-package consumers like CLI / internal-cli can opt out — a future "always required" tightening would break headless callers); ai_credits classification routes to notifyAiCreditsDepleted(userId, provider, message); ai_provider classification routes to notifyAiProviderError; git_auth classification routes to notifyGitAuthExpired(userId, provider) — provider only, no message (pinned: exposing the upstream Git error to the user adds no value and can leak internal hostnames / request IDs); account_level classification routes to notifyGenerationAccountError(userId, workId, workName, message) carrying the work context; unknown classification does NOT notify the user (pinned: random pipeline crashes — Zod parse failure on AI output, etc. — are for ops to investigate, not the end-user; a catch-all "tell the user something is wrong" refactor would create alert noise).

Pipeline-orchestrator helpers still pending a follow-up sweep: processGeneration (87 lines), executeGenerationPipeline (60 lines), finalizeGeneration (45 lines), runInProcessGeneration (20 lines), prepareProviders (21 lines), ensureProvidersEnabledForWork (43 lines), dispatchGenerationTask (55 lines — partially exercised via generateItems/updateItemsGenerator dispatch path). These multi-step orchestrators require richer mocks for dataGenerator.initialize (returns {success, stats, warnings, prUpdate, hasExistingItems, error}) and signal-aware markdownGenerator.initialize / websiteGenerator.initialize, plus exercising the EventEmitter2 emission of WorkGenerationCompletedEvent from the success path. Branch: tests/work-generation-helpers-coverage.

2026-05-09 — packages/agent small-service coverage sweep #6 (1 service — WorkGenerationService, 95 tests in 1 spec file, focused first sweep on wrapper / simpler methods, #634)

Closes a major chunk of the previously-zero-coverage packages/agent/src/services/work-generation.service.ts (1656 lines — the largest of the four originally-uncovered services on 2026-05-09 and the LAST remaining service in packages/agent/src/services/ without a dedicated unit suite). Suite passes via pnpm --filter @ever-works/agent test --testPathPattern='services/__tests__/work-generation.service' in 20s, total 95 tests; full agent suite 135 → 136 suites / 2754 → 2849 tests green. work-generation.service.spec.tsupdateDomainType (6 tests): runs ensureCanEdit BEFORE workRepository.update (order pinned via shared order array w/ mockImplementation push so an accidental swap that lets unauthorised callers mutate domainType breaks loudly); manuallySet defaults to true when omitted; explicit manuallySet=false is preserved (auto-detected branch — pinned because the auto-domain-detect path passes false so a future "default-true wins everywhere" refactor would clobber the user's explicit choice); documented success envelope shape {status:'success', domainType, domainTypeManuallySet}; HttpException rethrown verbatim via rejects.toBe(err) identity check; generic Error wrapped in BadRequestException w/ normalizeGeneratorError-mapped message. regenerateMarkdown (5 tests): runs ensureCanEdit BEFORE markdownGenerator.initialize (order pinned); forwards generation_method=RECREATE (NOT CREATE_UPDATE — pinned because regenerate-markdown is the user-facing "rebuild every *.md from data" action; a future swap to CREATE_UPDATE would silently leave stale markdown for items that no longer exist in the data repo); returns {status:'success'} envelope; HttpException passthrough; generic Error wrapped w/ id: workId in payload (NOT workId key — pinned distinct from updateReadme which uses workId). updateReadme (7 tests): returns {status:'skipped', updated:false, slug, message} envelope when templateUpdate.reason === 'not_initialized' AND does NOT call markdownGenerator.initialize (pinned: the not-initialized branch is the "user just created the work, repo not yet provisioned" case; UI relies on the distinct status:'skipped' to render a "create the repository first" prompt rather than a generic error); triggers markdownGenerator.initialize w/ CREATE_UPDATE ONLY when templateUpdate.updated === true; does NOT call initialize when updated:false AND reason !== 'not_initialized' (template already up to date branch); message-fallback chain (templateUpdate.message > 'README updated successfully.' (when updated) > 'README already up to date.' (when not updated)); HttpException passthrough; generic Error wrapped w/ workId in payload. updateWebsiteRepository (4 tests): forwards (work, user) positionally to websiteUpdateService.updateRepository; returns documented envelope w/ owner from work.getRepoOwner('website') (pinned literal 'website' role — NOT default 'data') AND repository: '<owner>/<repo>' composite via work.getWebsiteRepo() (no-arg); HttpException passthrough; generic Error wrapped w/ workId in payload. extractItemDetails (9 tests): returns error envelope w/o calling AI when extractContent returns undefined OR rawContent === '' (pinned: defence in depth — calling AI on empty content would hallucinate the item name from the URL); forwards content sliced to 12_000 chars + {userId} facadeOptions to aiFacade.askJson w/ pinned shape (temperature: 0.1, routing: { complexity: 'simple' }, variables.source_url + variables.content); omits the categoriesHint segment when existing_categories is undefined or empty (pinned via not.toContain('Prefer matching one of these existing categories') — the empty array branch must not pollute the prompt); appends categoriesHint w/ , -joined names when categories present; coerces falsy brand to undefined via || (NOT ?? — pinned because the LLM sometimes returns '' rather than null; swap to ?? would let empty-string through to the UI as a blank cell); filters images to http(s)-prefixed URLs only via .startsWith('http') — drops relative '/relative.png' AND 'data:image/png;base64,abc' AND 'javascript:alert(1)' (defence against AI returning unsafe-to-render URIs); coerces missing images to [] via || [] short-circuit; BadRequestException on AI failure w/ source_url + normalized Error.message in payload. bulkCaptureImages (16 tests): zero-items short-circuit returns {status:'success', message:'No items found in work', totalProcessed:0, ...} w/ NO screenshot calls; dto.itemSlugs filter narrows to specified slugs only; mode='missing' filters out items w/ existing populated images (pinned: a future swap to also include items with empty images: [] is fine but items w/ existing populated images MUST NEVER be re-captured — would double the screenshot bill); items w/o source_url dropped unconditionally; distinct messages for mode='missing' ('No items without images found') vs mode='all' ('No items with source URLs found') when nothing eligible; screenshotFacade.isAvailable() === falsestatus:'error' envelope w/ 'Screenshot service is not available' w/o invoking capture; forwards (capture-options, {userId, workId}) shape positionally w/ pinned options {url, blockAds:true, blockCookieBanners:true, cache:true}; cacheUrl preferred over imageUrl (pinned: cache is the CDN-cached version, faster + cheaper for end users); falls back to imageUrl when cacheUrl === ''; per-item rejection caught + recorded as error: error.message AND remaining items continue (partial-success path); non-Error rejection coerced to 'Unknown error' copy; status matrix — all-success → 'success', mixed → 'partial', all-fail (successCount===0 AND errorCount>0) → 'error'; HttpException passthrough from ensureCanEdit; generic Error from getItems wrapped in BadRequestException. cancelGeneration (7 tests): returns {mode:'already_finished'} envelope when work.generateStatus.status !== GENERATING AND does NOT touch findLatestInProgressByWork (pinned: short-circuit BEFORE history lookup saves a DB call AND must not touch the abort-controller map — a stale entry would crash on .abort()); triggerRunId set + no-dispatcher → finalizeCancelledGeneration + {mode:'stale'} (the dispatcher is @Optional — when absent, the row is marked CANCELLED locally so the UI doesn't show "stalled generation" forever); dispatcher cancel-success → finalize + {mode:'trigger'} w/ workScheduleService.finalizeScheduleRun(scheduleId, {status:'failed', reason:GENERATION_CANCELLED}); dispatcher cancel-fails AND refreshed work no longer GENERATING → {mode:'already_finished'} (race-condition recovery: the trigger run completed naturally between our findLatestInProgressByWork and the cancel call — must NOT mark the row CANCELLED in that case, asserted via recordGenerationFinishTime.not.toHaveBeenCalled()); dispatcher cancel-fails AND refreshed work STILL GENERATING → finalize + {mode:'stale'}; in-process abortController present → controller.abort(createGenerationCancelledError()) + {mode:'in_process'} w/ NO finalize call (the catch block in processGeneration handles cleanup via the AbortError); no triggerRunId AND no abortController → finalize + {mode:'stale'}. runScheduledUpdate (11 tests): uses schedule.user verbatim when present — does NOT call userRepository.findById (pinned: avoids an N+1 lookup on the cron path; callers preload via JOIN and we MUST trust it); falls back to userRepository.findById(schedule.userId) when schedule.user is unset; NotFoundException('User not found for scheduled update') when both missing AND finalizes schedule as failed w/ the error message; validateRunEntitlement === false{status:'skipped', message:'Entitlement check failed — schedule paused'} envelope + finalizeScheduleRun({status:'skipped', reason}) AND does NOT enter updateItemsGenerator (pinned: a downgraded user must not have their schedule rip through to a real generation — 'skipped' is the documented signal that the run was intentionally not executed); skipped envelope falls back to schedule.workId when work.slug missing; routes to runScheduledSync (path resolves to void) when work.sourceRepository.type is in SYNCABLE_SOURCE_TYPES (data_repo/awesome_readme/works_config); runScheduledSync records success:false outcome by calling handleSyncFailure w/ finalizeScheduleRun({status:'failed', reason}) + generationHistoryRepository.updateEntry w/ status: ERROR + errorMessage; non-syncable sourceRepository.type (e.g. 'mcp') falls through to the regular update flow (pinned: the syncable-set is closed — adding a 4th type must update both the predicate AND this regression guard); forwards schedule.providerOverrides into updateDto.providers AND alwaysCreatePullRequest into updateDto.update_with_pull_request; alwaysCreatePullRequest defaults to false via ?? false when undefined on schedule; outer try/catch finalizes schedule as failed AND rethrows when an inner step throws (pinned: markRunFailed is documented as idempotent so a duplicate call from inside updateItemsGenerator is harmless — the outer catch ensures the schedule does NOT linger in GENERATING when a step before any inner finalization throws). submitItem (8 tests): runs ensureCanEdit BEFORE itemSubmissionService.submitItem (order pinned); success + pr_number set → markdownGenerator.initialize w/ pr_update: {branch, title, body} AND does NOT recordActivityHistory (pinned: the activity-history recording is reserved for the direct-commit branch — PR-based submits get their history entry at PR-merge time via a separate listener); auto_merged: truegeneration_method: RECREATE (NOT CREATE_UPDATE — pinned because the auto-merge path bypasses PR review so the data repo is already in sync and we use RECREATE to rebuild ALL markdown including any cross-item references); success + no pr_numberrecordActivityHistory w/ ITEM_ADDED activityType + Item added: <name> summary + full WorkChangelog shape (pinned via expect.objectContaining); skips activity history when item_name OR item_slug missing on the success branch (defensive: legacy branches in itemSubmissionService can return success WITHOUT name/slug from aborted commit races — must not write a half-baked changelog row); status: 'error'BadRequestException w/ normalized message in payload; HttpException passthrough; generic Error caught + wrapped w/ workId + item_name in payload (pinned distinct from removeItem which uses slug + item_slug — different UI badges). removeItem (5 tests): success + no pr_numberrecordActivityHistory w/ ITEM_REMOVED + Item removed: <name> summary; success + pr_number set → no recordActivityHistory (PR-based removal); forces final status:'success' on the resolved envelope (overrides upstream — pinned current behaviour: source spreads result THEN hard-codes status:'success'; a future swap that preserves upstream status would silently let 'partial' (or other future values) leak); status:'error'BadRequestException; generic Error wrapped w/ slug: workId + item_slug in payload. updateItemMetadata (6 tests): success + no pr_numberrecordActivityHistory w/ ITEM_UPDATED activityType + updatedItemsCount: 1 + fieldsChanged whitelist filtered to the documented ['featured', 'order', 'source_url'] (pinned via dto.X !== undefined guards in source — a future addition that smuggles arbitrary dto fields in would change the activity-log shape that the UI depends on); fields omitted from fieldsChanged when undefined on dto (PATCH semantics — only present-keys appear); markdownGenerator.initialize w/ CREATE_UPDATE on success; success + pr_number set → no recordActivityHistory; status:'error'BadRequestException; generic Error wrapped w/ slug ONLY (asymmetry pin vs. removeItem: the update-error envelope does NOT include item_slug because the surface point is metadata-only (different UI badge) — a future symmetry refactor would change client expectations). generateItems (3 tests): ConflictException when work.generateStatus.status === GENERATING AND does NOT create history (pinned: ensureNotAlreadyGenerating is the documented race-prevention guard — a second concurrent request from the SAME user would otherwise blow up at the data-repo layer with two clones of the same dest dir; better to fail fast); awaitCompletion=false returns {status:'pending', slug, parameters, historyId, message} envelope w/ pinned message Processing request for '<dto.name>'. Check logs or data work for updates.; passes positional (workId, userId) to ensureCanEdit. updateItemsGenerator (8 tests): {status:'skipped', message:'Skipped — work already generating'} envelope when scheduled run hits a GENERATING work AND finalizeScheduleRun({status:'skipped', reason:'Work already has a generation in progress'}) (pinned: schedule-aware skip — the cron path MUST NOT 409 because a queue-driven retry would burn entitlement quota for a run that didn't happen); BadRequestException w/ documented copy 'Configuration invalid or missing. Please run a manual generation first.' when last_request_data missing; on missing-config + scheduled trigger → finalizes schedule as failed AND pauses the schedule (pinned: the pause is critical — without it, the next cron tick would just re-fail-and-finalize on the same broken config, burning entitlement quota indefinitely); uses initial_prompt instead of last_request_data.prompt for scheduled runs (pinned: scheduled runs MUST use the initial prompt because the user might have adjusted last_request_data.prompt to a one-off variant for a manual single-run debug — a future swap that always uses last_request_data.prompt would produce wildly inconsistent scheduled output); falls back to last_request_data.prompt via ?? when initial_prompt is missing; forces per-run defaults (generation_method: CREATE_UPDATE + update_with_pull_request: true) over last-request-data (pinned: a previous manual RECREATE run would otherwise poison every subsequent scheduled run, causing the data repo to be torn down and rebuilt every interval — perRunDefaults reset is the ONLY safety here); deep-merges providers — overrides win per-field, unset fields inherit from last run (pinned: a flat spread ...lastRequestData, ...updateDto would replace the entire providers object on every update; deep merge is what lets a user toggle ONE provider without resetting the rest); overrides config caps (max_search_queries: 10, max_results_per_query: 5, max_pages_to_process: 10, ai_first_generation_enabled: false) for scheduled runs (pinned: scheduled runs use trimmed caps to keep cost predictable). Mocking pattern: jest.mock('@src/generators/data-generator/data-generator.service', ...) + jest.mock('@src/generators/markdown-generator/markdown-generator.service', ...) + jest.mock('@src/generators/website-generator/website-generator.service', ...) + jest.mock('@src/generators/website-generator/website-update.service', ...) BEFORE the SUT import so the data-generator → p-map (ESM-only) chain is broken — DataGeneratorService/MarkdownGeneratorService/WebsiteGeneratorService/WebsiteUpdateService are consumed only as DI tokens, runtime behaviour fully substituted by hand-built {getConfig, getItems, updateMarkdownTemplate} / {initialize} / {updateRepository} mocks. Hand-built WorkRepository/WorkGenerationHistoryRepository/UserRepository/WorkOwnershipService/WorkScheduleService/WorkImportService/ItemSubmissionService/ScreenshotFacadeService/AiFacadeService/ContentExtractorFacadeService/GeneratorFormSchemaService/PluginOperationsService/PluginRegistryService/WorkGenerationDispatcher/NotificationService shapes typed as any so the suite executes in <20s with no NestJS context, no @nestjs/testing Test.createTestingModule(). Pipeline-orchestrator helpers remain pending a follow-up sweep: processGeneration (87 lines), executeGenerationPipeline (60 lines), finalizeGeneration (45 lines), runInProcessGeneration (20 lines), markGenerationStarted (18 lines), finalizeCancelledGeneration (38 lines), handleErrorNotification (16 lines), prepareProviders (21 lines), ensureProvidersEnabledForWork (43 lines), dispatchGenerationTask (55 lines — partially exercised via generateItems/updateItemsGenerator dispatch path), isNonFatalWebsiteGenerationError (12 lines), resolveGenerationFinalStatus (12 lines), resolveGenerationErrorMessage (12 lines), buildScheduleRunOutcome (10 lines). These methods orchestrate the full data → markdown → website pipeline w/ AbortController-backed cancellation, log collection, and three-step finalization across work/history/schedule rows; covering them requires building richer mocks for dataGenerator.initialize (returns {success, stats, warnings, prUpdate, hasExistingItems, error}), markdownGenerator.initialize (signal-aware), websiteGenerator.initialize (signal-aware), and exercising the EventEmitter2 emission of WorkGenerationCompletedEvent. Closes the agent-package services zero-coverage gap (every .service.ts file under packages/agent/src/services/ now has a dedicated __tests__/<name>.service.spec.ts companion). The depth-of-coverage gap on work-generation.service.ts pipeline orchestration is tracked as a follow-up sweep below.

2026-05-09 — packages/agent small-service coverage sweep #5 (1 service — ItemHealthService, 59 tests in 1 spec file, #632)

Closes the previously-zero-coverage packages/agent/src/services/item-health.service.ts (676 lines — the second-largest of the three originally-uncovered services on 2026-05-09; only work-generation.service.ts (1656 lines) remains). Suite passes via pnpm --filter @ever-works/agent test --testPathPattern='services/__tests__/item-health.service' in 15s, total 59 tests; full agent suite 134 → 135 suites / 2695 → 2754 tests green. item-health.service.spec.tscheckItem (8 tests): InternalServerErrorException w/ pinned message 'Item source validation service is not configured for manual checks' when WorkOwnershipService is undefined (the @Optional DI guard short-circuits BEFORE any other work — pinned because a future drop of the runtime guard would silently fall through to undefined.ensureCanEdit(...)); cache-hit returns the cached response BY REFERENCE and skips the entire checkWorkItems pipeline (cloneOrPull / DataRepository.create / checkLinks NEVER invoked) — ensureCanEdit STILL runs first (cache must not be readable without an access check, asserted via call-order); cache-miss path persists the response w/ documented MANUAL_RESPONSE_CACHE_MINUTES * 60 * 1000 = 5*60*1000ms TTL via cacheManager.set, key item-source-check:<workId>:<itemSlug>; item.slug || itemSlug fallback (the || operator pinned — ?? would let empty-string slug through silently); NotFoundException w/ documented copy Item '<slug>' has no source URL to check (vs Item '<slug>' not found) — pinned distinct copy because UI distinguishes "item present but unconfigured" from "item not in directory"; service tolerates undefined cacheManager (?. optional-chain on get/set must NOT crash). runScheduledCheck (3 tests): warn-and-rethrow on Error w/ Error.message interpolation including work.slug for triage; String(error) coercion for non-Error rejections (pinned because a future instanceof tightening would change the wire format of the warn line); returns the WorkHealthCheckResult envelope verbatim AND does NOT cache (schedule path is not user-facing, no per-response cache). checkWorkItems filtering (6 tests): drops falsy items (null/undefined), items without slug OR source_url, AND items inside the schedule-trigger SCHEDULE_RECHECK_CACHE_MINUTES = 24*60 freshness window — pinned via parametrised matrix because all three filter clauses are independent and a future merge into a single predicate must preserve all three; manual path does NOT skip recent items (shouldSkipCheck returns false when trigger !== 'schedule'); MANUAL_ACCURACY_CACHE_MINUTES = 60 causes manual AI re-runs after the window elapses; single-slug filter w/ no-match returns missingReason: 'not_found' envelope; URL dedup via [...new Set(...)] so two items pointing at the same URL produce a single checkLinks fetch — pinned check-links options shape {concurrency: 4, timeout: { request: 30000 }, retry: { limit: 2 }}. Commit + push pipeline (5 tests): order pinned via shared order array — addAll → commit → push; addAll(gitProvider, dataRepo.dir) positional, commit(gitProvider, dataRepo.dir, message, committer) positional, push({dir: cloneDest}, {userId: workOwner.id, providerId, workId}) positional (pinned because push uses the cloneOrPull dest, NOT dataRepo.dir, and the userId is the work owner's id — NOT the submitter's); commit-message plurality matrix 1 → 'item' / 2+ → 'items' AND verb-prefix matrix manual → 're-check' / schedule → 'refresh'; skips entire commit pipeline when checkedItems.length === 0 (no no-op commits — pinned because all-fresh-items would otherwise produce empty commits on every scheduled run); data.updateItem(slug, patch) null fallback to in-memory {...item, ...patch} so the response surface is consistent even if the data layer returns null on optimistic-update branch; changedCount increments only when health state actually changed via areHealthStatesEqual (pinned full 5-field equality: status / status_code / message / failure_count / checked_via). mapHealthStatus + buildItemHealth (16 tests): full 11-cell parametrised matrix mapping (status, statusCode) to ItemHealthStatusalive → healthy, invalid → broken (status takes precedence over statusCode), dead 404/410 → broken, dead 401/403/429 → unknown (auth wall / forbidden / rate-limit are NOT broken), dead 5xx → unknown, dead w/o statusCode → unknown, dead 400 → unknown (default catch-all); missing checkLinks entry → unknown health w/ failure_count = previous + 1 and pinned message 'Automated check could not verify the source URL'; healthy result resets failure_count to 0 regardless of previous count; non-healthy increments by 1; missing statusCode coerces to null via ?? null; 'invalid' status produces pinned message 'Invalid or unsupported source URL'. buildSourceValidation (15 tests): broken-health short-circuit returns {reachability_status: 'broken', accuracy_status: 'unknown', confidence_score: 1, ...} envelope WITHOUT calling aiFacade.askJson OR contentExtractorFacade.extractContent (pinned: a broken link must NEVER be sent to AI for accuracy judgment); broken-branch reason falls back to health.message || 'Source URL is broken'; aiFacade.isConfigured() === false returns degraded envelope w/ accuracy_status: 'unknown' AND pinned reason 'AI source validation is not configured' (graceful-degradation when no AI provider is configured); AI prompt + schema + variables + context shape pinned (temperature: 0, routing: { complexity: 'simple', autoEscalate: true }, userId + workId in context envelope); contentExtractorFacade.extractContent(url, undefined, {userId, workId}) 3-arg shape pinned (provider override is undefined — system-level choice); undefined suggested_source_url from AI coerced to null via ?? null in persisted validation; AI rejection caught and downgraded to {accuracy_status: 'unknown', reason: 'AI could not validate this source'} envelope w/ warn log (Error.message OR String(error) coercion for non-Error); resolveReachabilityStatus upgrades base 'unknown' to 'reachable' when extracted page content trims to >100 chars (pinned > not >=); empty rawContent coerces to '' via extracted?.rawContent?.slice(0, 2000) || ''; manual-path cache reuse: when getReusableAccuracyValidation returns the cached row AND base reachability is 'broken', the broken short-circuit fires FIRST (cached AI result unused — pinned because broken-link state must NEVER show stale "accurate" verdict); when base reachability is 'unknown', cached reachability_status is preserved (the override-vs-preserve gate is baseReachabilityStatus === 'unknown' ? cached : base — pinned ternary); prompt sanitisation strips [INST]/[/INST]/<|im_start|>/<|im_end|>/<|system|> markers AND collapses CR/LF to spaces (defends against prompt-injection from user-supplied item.name/description); per-field maxLength caps via slice — name=200, description=500, pageContent=2000. Manual message builder edge cases (4 tests): pinned message composition with broken+unknown branches; validation.reason omitted when accuracy_status === 'accurate' (pinned because UI noise — green-check is self-explanatory); always starts with 'Item source check completed.' prefix; 'Reachability: automated check was inconclusive.' for unknown reachability. cloneOrPull positional contract (1 test): forwards {owner, repo, committer} w/ repo: work.getDataRepo() AND owner: work.getRepoOwner() (default 'data' role) AND committer: work.resolveCommitter(submitter) AS the SUBMITTER's identity (NOT the work owner's — distinct because commits attribute to the user who clicked recheck) AND context envelope {userId: workOwner.id, providerId: work.gitProvider, workId: work.id} (pinned because userId is the WORK OWNER's id so the right credential set is used regardless of WHO triggered the recheck). loadChecker invocation (1 test): check-links dynamic import is awaited inside checkWorkItems per-call (NOT cached on the instance), so two manual calls re-load twice (Node module registry caches the import internally so re-resolution is essentially free, AND keeps the service hot-reload-safe in dev). Mocking pattern: jest.mock('../../generators/data-generator/data-repository', ...) BEFORE the SUT import so the data-generator → fs-extra/isomorphic-git chain is broken — DataRepository.create is a jest.fn() returning a hand-built {getItems, updateItem, dir} mock; check-links virtual mock w/ a hoisted checkLinksMock = jest.fn() returning a stub-returning-default-export shape so the dynamic import('check-links') resolves to our controlled function. Hand-built GitFacadeService/AiFacadeService/ContentExtractorFacadeService/Cache/WorkOwnershipService shapes typed as any so the suite executes in <15s with no NestJS context, no @nestjs/testing Test.createTestingModule(). Closes 1 of the 2 remaining zero-coverage services in packages/agent/src/services/ (the only remaining service — work-generation.service.ts (1656 lines) — is pending a follow-up sweep).

2026-05-09 — packages/agent small-service coverage sweep #4 (1 service — WorkTaxonomyService, 61 tests in 1 spec file, #630)

Closes the previously-zero-coverage packages/agent/src/services/work-taxonomy.service.ts (586 lines — the largest of the remaining four uncovered services). Suite passes via pnpm --filter @ever-works/agent test --testPathPattern='services/__tests__/work-taxonomy.service' in 13s, total 61 tests; full agent suite 133 → 134 suites / 2634 → 2695 tests green. work-taxonomy.service.spec.tsensureUser observed via getCategories (2 tests): NotFoundException with User not found: <userId> interpolation when userRepository.findById returns null AND data-generator NEVER queried (pinned: a deleted-user race must NOT mutate the data repo); positional userId forwarding. getCategories (5 tests): runs ensureAccess (NOT ensureCanEdit) BEFORE ensureUser BEFORE getCategoriesTags (order pinned via shared order: string[] array w/ mockImplementation); returns categories array verbatim; coerces missing categories to [] via || [] short-circuit; forwards (work, user) positionally to data-generator; short-circuits w/o user-repo when ensureAccess rejects. createCategory (8 tests): runs ensureCanEdit (NOT ensureAccess) and full chain order pinned (ensureCanEdit → findById → getCategoriesTags → saveCategories → createEntry); BadRequestException w/ 'A category with this name already exists' on case-insensitive + whitespace-insensitive duplicate (pinned regression guard for ' TOOLS ' vs 'Tools' collision); slugifies trimmed name into id + trims description/icon_url/priority passthrough; immutable spread [...categories, newCategory] (pinned via separate test asserting the original array length is preserved); writes CATEGORY_CHANGE history entry with status=GENERATED + durationInSeconds=0 + triggeredBy='user' + startedAt===finishedAt (single fresh Date capture); ?.trim() on undefined optional fields short-circuits to undefined (pinned: a future swap to (dto.x ?? '').trim() would change wire shape — undefined survives as undefined, NOT empty-string); documented success envelope shape; short-circuits when ensureCanEdit rejects. updateCategory (10 tests): NotFoundException on missing categoryId; BadRequestException on rename collision (case-insensitive); allows renaming to the SAME category (case+whitespace ignored — c.id !== categoryId excludes self); spread-merge preserves existing fields and overrides only provided ones; explicit description: null clears the field via ?.trim() short-circuit (pinned because dto.description !== undefined gate fires for null AND null?.trim() resolves to undefined — the documented "clear this field" sentinel); PATCH semantics — undefined fields NOT touched; empty-string name does NOT update (pinned current behaviour: if (dto.name) short-circuits empty string — a future swap to if (dto.name !== undefined) would let blanks through and produce a category with empty name); CATEGORY_CHANGE history with whitelist-filtered fieldsChanged (only ['name','description','icon_url','priority'] AND only those NOT undefined — something_else rejected); array mutated in-place via index assignment; short-circuits when ensureCanEdit rejects. deleteCategory (5 tests): NotFoundException on missing categoryId; in-place splice removes the row + saves the shrunk list; CATEGORY_CHANGE history with action="removed" + name+slug; documented success envelope {status:'success', message:'Category deleted successfully'}; short-circuits when ensureCanEdit rejects. getTags (2 tests): runs ensureAccess (NOT ensureCanEdit); coerces missing tags to []. createTag (4 tests): rejects on case-insensitive duplicate; writes only {id, name} — no description/icon_url/priority (pinned because Tag is a thin shape; a future addition like color would need to update createTag AND this test together); TAG_CHANGE history (NOT CATEGORY_CHANGE — pinned activity-type discriminator); success envelope. updateTag (5 tests): NotFoundException on missing; rejects on duplicate against another tag; preserves existing id when name is renamed — id is NOT re-slugified (pinned because items reference tags by slug — re-slugifying would orphan items); empty/missing dto.name → no-op return + STILL writes a history entry (pinned current behaviour: a future "no-op skips history" tightening would be a deliberate change); fieldsChanged empty for all-undefined dto. deleteTag (3 tests): NotFoundException on missing; in-place splice + TAG_CHANGE history; success envelope. getCollections (2 tests): mirrors getTags shape with COLLECTION_CHANGE activity type. createCollection (4 tests): rejects on duplicate; slugifies + trims optional fields (description/icon_url/priority — Collection has the full Category-shaped fields, NOT thin like Tag); COLLECTION_CHANGE history; documented envelope. updateCollection (4 tests): NotFoundException on missing; rejects on rename collision; spread-merge preserves existing; COLLECTION_CHANGE history with whitelist filter (extra field rejected). deleteCollection (3 tests): NotFoundException on missing; in-place splice + COLLECTION_CHANGE history; success envelope. recordTaxonomyHistory observed via createCategory (4 tests): always writes triggeredBy='user' (pinned: taxonomy edits are always user-initiated; a future schedule-driven taxonomy mutation would need to widen this); always writes status=GENERATED + durationInSeconds=0; does NOT swallow errors from createEntry — propagates up to the caller (pinned: history-write failures MUST surface because taxonomy is committed to the data repo BEFORE history is written, so a silent history failure would create an audit-log gap); forwards startedAt/finishedAt as the SAME Date instance (single now = new Date() capture). Mocking pattern: jest.mock('@src/generators/data-generator/data-generator.service', ...) BEFORE the SUT import so the data-generator → p-map (ESM-only) chain is broken — DataGeneratorService is consumed only as a DI token in this service, runtime behaviour fully substituted by hand-built {getCategoriesTags, saveCategories, saveTags, saveCollections} mock. Hand-built WorkOwnershipService/UserRepository/WorkGenerationHistoryRepository shapes typed as any so the suite executes in <13s with no NestJS context, no @nestjs/testing Test.createTestingModule(). Closes 1 of the 3 remaining zero-coverage services in packages/agent/src/services/ (the other 2 — item-health (676 lines), work-generation (1656 lines) — remain pending follow-up sweeps).

2026-05-09 — packages/agent small-service coverage sweep #3 (1 service — WorkMemberService, 43 tests in 1 spec file, #628)

Closes the previously-zero-coverage packages/agent/src/services/work-member.service.ts (223 lines) in the canonical __tests__/<name>.service.spec.ts layout. Suite passes via pnpm --filter @ever-works/agent test --testPathPattern='services/__tests__/work-member.service' in 12s, total 43 tests; full agent suite 132 → 133 suites / 2591 → 2634 tests green. work-member.service.spec.tslistMembers (3 tests): runs ownershipService.ensureCanView(workId, userId) BEFORE memberRepository.findByWork (order pinned via shared order: string[] array w/ mockImplementation), maps each member through toDto, short-circuits w/o repo on ensureCanView rejection, returns [] when no rows. inviteMember (10 tests): full happy-path order pinned (ensureCanManageMembers → role-validate → findByEmail → creator-check → findMember (existing) → findById(inviter)addMemberfindById(memberWithRelations)); rejects WorkMemberRole.OWNER AND arbitrary string roles via BadRequestException BEFORE any repo call (pinned because OWNER is reserved for the work creator — a future allow would silently let any manager promote a member to owner); parametrized it.each accepts every assignable role (MANAGER/EDITOR/VIEWER); regression-guard pin on ASSIGNABLE_MEMBER_ROLES = [MANAGER, EDITOR, VIEWER] (a future OWNER inclusion must be a deliberate breaking change); NotFoundException with email-interpolated message (User with email '<email>' not found) AND no further repo calls when invitee email is unregistered; BadRequestException w/ 'Cannot add the work creator as a member' when invitee.id matches work.userId (compared against work.userId from the ownership-service envelope, NOT userId from the caller — pinned because the creator is the row owner regardless of who's inviting); BadRequestException when findMember returns existing row (idempotent re-invite refused — pinned because addMember would otherwise produce a duplicate row); short-circuits when ensureCanManageMembers rejects; forwards positional (workId, inviteeId, role, inviterId) to addMember (4-arg shape pinned — caller userId is inviterId, NOT a 5th option-bag argument). updateMemberRole (7 tests): happy-path order pinned (ensureCanManageMembersfindById → role-validate → updateRole); NotFoundException when member missing OR member.workId !== workId (cross-work safety pin: if a hypothetical wrong-work member id were ever passed in by a buggy UI, the service must NOT mutate that member); BadRequestException on OWNER OR arbitrary string roles BEFORE repo call; forwards (workId, member.userId, role) to updateRole — uses member.userId from the looked-up row, NOT the memberId arg (pinned because the composite key for the row is (workId, userId), not (workId, memberId) — a future swap would silently target the wrong row when memberId happens to collide with a valid userId in another work); short-circuits on ensureCanManageMembers rejection. removeMember (5 tests): happy-path order pinned, NotFoundException on missing AND cross-work member, does NOT inspect the boolean returned by memberRepository.removeMember (pinned current behaviour: removeMember resolves void even when the row was already gone — asymmetric with leaveWork which DOES throw on false; a future tightening to symmetry would be a deliberate change), short-circuits on ensureCanManageMembers rejection. leaveWork (4 tests): happy-path order pinned (ensureCanViewremoveMember(workId, userId)); BadRequestException w/ 'Work creator cannot leave the work' when isCreator: true from the ownership-service envelope (pinned because a creator leaving would orphan the work — the creator must transfer ownership first); NotFoundException w/ 'You are not a member of this work' when removeMember returns false (asymmetric with admin-side removeMember — pinned because leaveWork is user-initiated and the user MUST get a clear error if they think they're a member but aren't); short-circuits on ensureCanView rejection. getMember (4 tests): happy-path order pinned, NotFoundException on missing AND cross-work, short-circuits on ensureCanView rejection. getWorkOwnerInfo (3 tests): returns {id, username, email, avatar} projection from work.user (full snapshot, NOT email-only — pinned because UI renders avatar+username on the work card), passes through undefined avatar verbatim, short-circuits on ensureCanView rejection. toDto observed via listMembers (5 tests): falls back to 'Unknown' username AND empty-string email when member.user is missing (defensive coercion for orphan rows from a deleted-user race); falls back to 'Unknown' for empty-string username via the || short-circuit (NOT ?? — pinned because ?? would let empty-string through as a valid display name, which would render a blank cell in the UI); omits invitedBy when member.invitedBy is undefined; preserves only {id, username} from invitedBy (NOT email/avatar — minimal projection so a future user-data leak via member listing is impossible); serialises createdAt via Date.toISOString() for JSON-safe wire format. Mocking pattern follows work-ownership.service.spec.ts: hand-built {findByWork, findMember, findById, addMember, updateRole, removeMember} and {findByEmail, findById} shapes typed as any (the service narrowly consumes only those methods); WorkOwnershipService is mocked w/ ensureCanView/ensureCanManageMembers returning the documented {work, isCreator} envelope so the service's destructuring works without invoking real ownership logic. Closes 1 of the 4 remaining zero-coverage services in packages/agent/src/services/ (the other 3 — item-health, work-generation, work-taxonomy — remain pending follow-up sweeps).

2026-05-09 — packages/agent small-service coverage sweep #2 (3 services — ItemSourceValidationSchedulerService + WorkDetailService + RepositoryManagementService, 74 tests across 3 spec files, #626)

Closes the next 3 of the previously-zero-coverage packages/agent/src/services/*.ts files in the canonical __tests__/<name>.service.spec.ts layout. All three suites pass via pnpm --filter @ever-works/agent test --testPathPattern='services/__tests__/(item-source-validation-scheduler|work-detail|repository-management)' in 19s, total 74 tests; full agent suite 132 suites / 2591 tests green. item-source-validation-scheduler.service.spec.ts (29 tests, source 130 lines) — processDueSchedules (12 tests): forwards documented LIMIT=50 cap to findDueSourceValidation (pinned regression guard so a future widening to per-tier paging is deliberate), all-zero envelope when no work is due, skips work with missing user relation WITHOUT invoking the health check, skips work with missing sourceValidationCadence (an enabled work with no cadence is a config bug — the scheduler must NOT crash, since the next-run timestamp is computed from cadence and there's no way to reschedule without one), skips when both user AND cadence missing (no double-counting), happy-path full chain (runScheduledCheck → calculateNextRun → updateSourceValidationRun with aggregated checkedCount/changedCount), calculateNextRun invoked positionally w/ (cadence, 0, freshDate) — failure-count is 0 (every successful run is a fresh start; carry-over would be a deliberate change), from arg is new Date() captured between before/after timestamps; error path captures Error.message into result.errors array and logs error.stack (pinned: only error instanceof Error yields a stack); non-Error rejection coerced via String() w/ undefined for stack (pinned defensive coercion); errors do NOT short-circuit — remaining works continue (3-work mid-failure scenario aggregates 2 successes + 1 error); aggregation across multiple works sums checkedCount/changedCount correctly; skipping does NOT touch updateSourceValidationRun OR calculateNextRun. getSettings (5 tests): NotFoundException with workId interpolated when work missing, populated row maps verbatim w/ ISO-8601 timestamps via Date.toISOString(), missing cadence/nextRunAt/lastRunAt coerced to null via ?? null short-circuit (the optional-chain ?.toISOString() short-circuits to undefined when Date is null, then ?? null coerces to null), allowedCadences forwarded verbatim w/ toBe identity check (no defensive copy), empty allowedCadences passed through verbatim. updateSettings (12 tests): NotFoundException when work missing (no allowed-cadences check, no update), falls back to work-stored cadence when dto.cadence undefined (dto.cadence ?? work.sourceValidationCadence ?? null chain — caller-not-changing-cadence preserves stored value), coerces missing cadence on BOTH dto AND work to null, BadRequestException when cadence set AND not in non-empty allowedCadences ("Cadence '' is not allowed by your subscription plan" copy); SKIPS the gate when allowedCadences is empty (kill-switch off — pinned because empty-array is the "subscriptions disabled / unbounded" signal, NOT "nothing allowed"); SKIPS the gate when cadence resolves to null (the gate is cadence && allowedCadences.length > 0 && !some(...) — short-circuits on falsy cadence so a "disable" call does not require any allowed cadence); passes when cadence matches allowed entry; persists nextRunAt: null when enabled: false w/o calling calculateNextRun (the dto.enabled && cadence guard is short-circuit-on-disabled — disabling must NOT compute a new next-run); persists nextRunAt: null when cadence resolves to null regardless of enabled; computes nextRunAt via single-arg calculateNextRun(cadence) when enabled+cadence both set (NO failure-count, NO from-date — pinned ONE-arg signature different from the processDueSchedules path); preserves work-stored lastRunAt in response AND does NOT include sourceValidationLastRunAt key in the update payload (lastRunAt is owned by the run path, not the settings path — updating settings must NOT erase the historical run marker); returns dto.enabled verbatim in response (NOT the stored value); allowedCadences forwarded by reference (no defensive copy). work-detail.service.spec.ts (24 tests, source 136 lines) — module-level exports (2 tests): WORK_DETAIL_PROMPT contains {name} AND {prompt} interpolation tokens (pinned because the AI facade does the variable substitution — if these tokens are renamed without updating the facade contract, the prompt would silently send the literal {name} string to the LLM and tank quality); workDetailSchema accepts {description: string, keywords: string[], categories: string[] | null} AND rejects undefined-categories (the schema is .nullable() NOT .optional() — the AI is forced to emit null rather than silently omit; a future swap to .optional() would change the API contract). Happy path (7 tests): forwards prompt + schema + {temperature: 0, variables: {name, prompt}, routing: {complexity: 'simple'}} + {userId, providerOverride} to aiFacade.askJson (pinned shape because temperature:0 = deterministic metadata extraction, routing.complexity:'simple' = cheap extract-only, userId from user.id); undefined providerOverride forwarded verbatim when aiProvider arg omitted; sanitizes AI-returned description (strips newlines and control chars for GitHub compat — multi-line input becomes single-line); sanitizes keywords array (trims, drops empties); sanitizes categories array (trims, drops empties); coerces null categories to [] BEFORE sanitisation (pinned regression guard — result.categories || [] in source); returns documented WorkDetails envelope w/ name preserved verbatim; logs extraction-start at log level. Slug uniqueness (4 tests): returns base slug when no row collides; appends -1 when base exists and -1 is free; keeps incrementing counter until free slug found (the loop is while (await ...) — a future max-iterations guard would change the contract); scopes existence check to userId (slugs are PER-USER unique). Fallback path on AI failure (4 tests): falls back to synthetic envelope when aiFacade.askJson rejects with Error (catch block must NOT rethrow — work creation flow depends on always getting metadata back; minimal fallback so user can edit later), pinned Error: 'AI provider down' triggers errorSpy('Error extracting details for work Recovery Work: AI provider down', 'stack-trace-here'); still applies sanitizeDescription to fallback ('Work for <name>' template — pinned via newline-containing name produces 'Work for My Multi-line Name'); uses name.toLowerCase().trim() as single-element keyword in fallback; still resolves a unique slug in the fallback path (catch block re-runs generateUniqueSlug so the fallback envelope still gets a non-colliding slug). Slug derivation rules (2 tests): lowercases and hyphenates multi-word names; handles punctuation and produces clean slug matching ^[a-z0-9]+(-[a-z0-9]+)*$. Module mock pattern: WorkScheduleService and ItemHealthService modules are stubbed via jest.mock('../work-schedule.service', ...) BEFORE the SUT import to avoid pulling in p-map (ESM-only) via the data-generator → work-schedule chain — only consumed as DI tokens, runtime behaviour fully substituted by hand-built mocks. repository-management.service.spec.ts (21 tests, source 138 lines) — getRepositoriesStatus (11 tests): queries gitFacade.getRepository for ALL THREE repository roles in parallel via Promise.all (source uses hard-coded [data, work, website] array — a future addition like a 4th repo role MUST be deliberate); getRepoOwner called per role (3 calls, ordered data → work → website) AND mock distinguishes by role (returns 'data-owner' for data/work, 'website-owner' for website — a future swap to a hard-coded role argument breaks loudly); forwards documented (userId, providerId, workId) context envelope to gitFacade.getRepository; populated repo maps to full status shape; null/undefined gitFacade response → exists=false w/ safe-default isPrivate: true (pinned: when upstream returns null we MUST still return a fully-populated entry so UI can render "Initialise repository" button; default true is the safe assumption — never mis-report private as public); thrown gitFacade rejection → exists=false (silently swallows error — 404s are the expected "repo not yet initialised" signal, NOT failures; a future tightening to rethrow on non-404 would be deliberate); partial-existence mix (data exists, others fail) without mis-aligning entries; persists freshly-computed visibility cache when work has NO prior visibility (missing repoVisibility is the "never queried" state — first call MUST seed the cache); SKIPS the cache write when freshly-computed visibility matches cached value (write-amplification guard — most common case is "nothing changed"); writes cache when ONLY the data flag changed; writes cache when ONLY the work flag changed; writes cache when ONLY the website flag changed; falls back to isPrivate=true for missing entries (results.find(r => r.type === 'X')?.isPrivate ?? true — defence-in-depth). updateRepositoryVisibility (10 tests): parametrized matrix via it.each for data/work/website repoTypes — each forwards correct (owner, repoName, {isPrivate}, ctx) tuple (data→data-owner+data-repo, work→data-owner+main-repo, website→website-owner+website-repo); throws "Invalid repository type" on unknown repoType WITHOUT facade call OR cache write; uses upstream-reported isPrivate (NOT the requested value) in cache write (pinned: GitHub may refuse a privacy change e.g. on free-tier private-repo limit — upstream response is source of truth, caching requested value would diverge from reality); seeds fresh visibility object when work.repoVisibility is undefined (initial visibility is private-by-default seed {data:true, work:true, website:true} so updating a single repo does not leak unset values); preserves other two flags when updating one role (cache patch, not replace); does NOT mutate work.repoVisibility in-place (uses spread copy {...currentVisibility} — mutating in place would defeat TypeORM change detection, asserted via two checks: original cached object unchanged AND written payload's repoVisibility !== cached); returns populated RepositoryStatus envelope from upstream values; always sets exists=true on returned status (a successful updateRepository call presupposes the repo exists — must reflect that even if a future caller forgets to re-query); propagates gitFacade.updateRepository rejection verbatim WITHOUT swallowing AND WITHOUT cache write (pinned in CONTRAST to getRepositoriesStatus which tolerates 404s — the update path MUST surface failures so UI can render error toast); forwards user.id/work.gitProvider/work.id into upstream context envelope. Mocking pattern: hand-built Work factory w/ getDataRepo/getMainRepo/getWebsiteRepo/getRepoOwner as jest.fn() so we can pin both invocation counts AND positional args; the getRepoOwner mock distinguishes by role (returns 'website-owner' for website, 'data-owner' for data/work). Closes 3 of the 7 remaining zero-coverage services in packages/agent/src/services/ (the other 4 — item-health, work-generation, work-member, work-taxonomy — remain pending follow-up sweeps).

2026-05-09 — packages/agent small-service coverage sweep (2 services — WorkAdvancedPromptsService + WorkWebsiteRepositoryStateService, 46 tests across 2 spec files, #624)

Closes the two smallest previously-zero-coverage packages/agent/src/services/*.ts files in the canonical __tests__/<name>.service.spec.ts layout. Both suites pass via pnpm --filter @ever-works/agent test --testPathPattern=services/__tests__/work-advanced-prompts.service|services/__tests__/work-website-repository-state.service in 16s, total 46 tests. work-advanced-prompts.service.spec.ts (24 tests, source 93 lines) — getAdvancedPrompts (7 tests): runs ownershipService.ensureAccess(workId, userId) BEFORE repository.findByWorkId (order pinned via shared order: string[] array w/ mockImplementation), forwards positional (workId, userId), does NOT invoke ensureCanEdit (viewer access is sufficient — pinned because a future tightening would be a deliberate change), short-circuits w/o touching repo on ensureAccess rejection, returns the all-null envelope keyed by caller workId when no row exists, uses caller-supplied workId in the response — NOT the row's workId (pinned defence-in-depth: if a hypothetical "findByWorkId returns the wrong row" bug were ever introduced, the response would still echo the requested id rather than leak someone else's prompts), maps every populated field verbatim incl. ISO 8601 updatedAt via Date.toISOString(), coerces missing prompt fields (undefined/null) to null via the documented ?? null operator while preserving explicit empty-strings (pinned because ?? does NOT coerce empty-string — a future swap to || would silently flip empty-string back to null and break the "user explicitly cleared this prompt" UI signal), coerces missing updatedAt to null. updateAdvancedPrompts (8 tests): runs ensureCanEdit BEFORE repository.createOrUpdate (order pinned), does NOT call ensureAccess (editor strictly subsumes viewer — pinned), forwards EXACTLY the 7 documented prompt keys to the repository (relevanceAssessment/itemGeneration/itemExtraction/searchQuery/categorization/deduplication/sourceValidation) and pins the regression guard against a "spread the dto" refactor that would smuggle workId/userId/arbitrary extras into the persisted row (asserted via Object.keys(payload).sort() exact match + not.toHaveProperty('workId'|'userId'|'evil')), forwards undefined fields verbatim WITHOUT any default coercion (pinned current behaviour — the repository decides what undefined means), forwards explicit null values verbatim (the documented "clear this prompt" sentinel), returns the updated row mapped through toResponseDto using the caller workId (NOT the row's workId — same defence-in-depth pin as the read path), short-circuits on ensureCanEdit rejection, propagates repository rejection verbatim. getPromptsForGeneration (4 tests): forwards workId to repository.findByWorkId WITHOUT calling either ownership method (pipeline-internal, documented "no auth" because the user-facing access check has already run on the work itself — pinned because a future tightening would be a deliberate change), returns the row REFERENCE verbatim via toBe identity check (no defensive copy, no toResponseDto mapping — pipeline consumers depend on the entity shape with Date objects on createdAt/updatedAt rather than the ISO-string projection used by the HTTP response DTO), null branch, repository-rejection passthrough. deleteAdvancedPrompts (5 tests): runs ensureCanEdit BEFORE repository.delete(workId) (order pinned), returns the repo's boolean verbatim for both true/false branches, short-circuits on ensureCanEdit rejection, propagates repository rejection. work-website-repository-state.service.spec.ts (22 tests, source 58 lines) — short-circuit matrix (7 tests): every documented marker (website / deployProjectId / websiteTemplateLastCommit / websiteTemplateLastUpdatedAt / websiteTemplateLastCheckedAt) is an INDEPENDENT "this work is initialised" signal — pinned via it.each so a future refactor that AND'd them together would silently break the domainType auto-detection paths that depend on this service; on short-circuit gitFacade AND the work.getRepoOwner/work.getWebsiteRepo helpers are NEVER invoked (the work-entity helpers are pinned via (work.getRepoOwner as jest.Mock).mock.calls.toHaveLength(0)); negative-of-the-positive: every-marker-falsy proceeds to the credentials check (pins the strict OR semantics); empty-string website is treated as falsy (does NOT short-circuit — pinned because if (work.website || ...) uses truthiness, a future swap to work.website != null would silently start treating empty-string as "initialised"). Credential iteration (5 tests): forwards both creator (work.userId) AND caller (user.id) when distinct, deduped via new Set(...); iteration order pinned caller-FIRST then creator-SECOND (matches the source's [user.id, work.userId] array construction order — a future [work.userId, user.id] swap would change which credential set is tried first AND change the repositoryExists short-circuit semantics); dedupes when caller IS the creator (Set collapses duplicate to ONE iteration); .filter(Boolean) drops both undefined AND empty-string userIds (pinned via two separate tests because the source uses truthy-check rather than nullish-check — a future swap to ?? null would let empty-string through and try to authenticate against an empty user); returns false w/ ZERO gitFacade calls when both userIds are falsy and no marker is set; skips repositoryExists for userIds that lack credentials and CONTINUES to the next user (pins the per-user continue rather than a global short-circuit). repositoryExists branches (7 tests): returns true on the FIRST user whose repository exists and short-circuits remaining users (pins via gitFacade.repositoryExists.toHaveBeenCalledTimes(1) even when 2 users iterate); forwards (owner, repo, authOptions) POSITIONALLY to repositoryExists w/ work.getRepoOwner('website') (literal 'website' role pinned — NOT the default 'data' role) + work.getWebsiteRepo() (no-arg) + the {userId, providerId, workId} envelope; continues to the next user when repositoryExists resolves to false; returns false when every userId has credentials but no lookup finds the repo; warns and continues when repositoryExists rejects with an Error (uses Error.message — message contains workId for log triage); warns and continues when repositoryExists rejects with a non-Error (uses String(error) coercion — 'plain-string-rejection' literal pinned in the warn output); returns false when EVERY user lookup throws (all swallowed via warn, ONE warn call per user, no rethrow); does NOT swallow rejections from hasValidCredentials (only repositoryExists is wrapped in the try/catch — pinned current behaviour because a future widening to also wrap the credentials check would be a deliberate change). Mocking pattern: hand-built { hasValidCredentials, repositoryExists } shape typed as any (the service narrowly consumes only those two methods of GitFacadeService); Work constructed via buildWork factory w/ getRepoOwner/getWebsiteRepo as jest.fn() so we can pin both their invocation counts AND their positional args; logger warn channel silenced via jest.spyOn((service as any).logger, 'warn').mockImplementation(...) and reasserted via the spy in the warn-path tests. Closes 2 of the 9 remaining zero-coverage services in packages/agent/src/services/ (the other 7 — item-health, item-source-validation-scheduler, repository-management, work-detail, work-generation, work-member, work-taxonomy — remain pending follow-up sweeps).

2026-05-09 — packages/agent WorkOwnershipService unit coverage (36 tests in services/__tests__/work-ownership.service.spec.ts, #622)

Closes the previously-zero-coverage packages/agent/src/services/work-ownership.service.ts (the only WorkOwnershipService consumer-facing file across the agent package). 36 tests across 4 nested describe blocks; full agent suite 2435 → 2471 green across 126 → 127 suites. ensureAccess (19 tests): NotFoundException with {status:'error', message: \Work with id '' not found`}envelope whenfindByIdreturns null (member-repo NEVER consulted — pinned vianot.toHaveBeenCalled()); creator path (work.userId === userId) returns {work, member:null, role:OWNER, isCreator:true}and SKIPS thefindMemberlookup entirely (so a missing member row does not 403 the creator); creator passes EVERY documentedminimumRole (OWNER/MANAGER/EDITOR/VIEWER) via it.eachw/o invokingmember.hasRoleOrHigher(the creator branch has its ownroleIsAtLeast(OWNER, …)private gate); non-creator with no membership row → ForbiddenException w/ "You do not have permission to access this work" copy; non-creator with member row + NO minimumRole returns{work, member, role, isCreator:false}AND does NOT invokemember.hasRoleOrHigher(the gate is conditional onminimumRolebeing supplied — pinned because a future "always check" refactor would change the call shape); non-creator with member row but role below minimumRole → ForbiddenException w/ "You do not have the required permission level for this action" copy ANDhasRoleOrHigher IS invoked w/ the requested minimum; non-creator passes the gate when role is sufficient; full 9-cell role-hierarchy matrix (MANAGER/EDITOR/VIEWERmember ×MANAGER/EDITOR/VIEWERminimum) parametrized viait.eachso a future swap of the hierarchy weights breaks loudly. **Convenience methods** (5 tests):ensureCanView/ensureCanEdit/ensureCanManageMembers/ensureIsOwnereach delegate toensureAccessw/ the documentedWorkMemberRole enum (VIEWER/EDITOR/MANAGER/OWNERrespectively) — pinned viajest.spyOn(service, 'ensureAccess')capture so a future direct-implementation refactor breaks loudly; all four propagate the underlying ForbiddenException verbatim viarejects.toBe(err) identity preservation. **hasAccess** (5 tests): true on resolve, false on NotFoundException, false on ForbiddenException, false on generic Error(any throw → false — pinned because the barecatchblock has no error-class filter), forwards(workId, userId)positional pair toensureAccess WITHOUT a minimumRole arg (spy.mock.calls[0]).toHaveLength(2)— pinned becausehasAccess is "any access at all" and a future switch to "viewer-or-higher" would change the contract). **getUserRole** (7 tests): null on missing work (member-repo NEVER consulted), OWNER for creator (member-repo NEVER consulted — same short-circuit as ensureAccess), each of MANAGER/EDITOR/VIEWER returned for non-creator member rows via it.each, null when neither creator nor member, AND a defensive coercion test pinning the member?.role || nullshort-circuit (a member row w/ falsyrole:''returns null because the||operator coerces empty-string to null — distinct from??which would return''). Mocking pattern: hand-built { findById }and{ findMember }shapes typedas any(NOTPick<Repository, …>because the service uses high-level repository methods rather than TypeORM primitives); the suite executes in <12s with no NestJS context, no@nestjs/testing Test.createTestingModule(), no real ForbiddenException construction — just new WorkOwnershipService(workRepository, workMemberRepository). Closes the largest remaining zero-coverage file in packages/agent/src/services/(the other 9 uncovered services in that folder —item-health, item-source-validation-scheduler, repository-management, work-advanced-prompts, work-detail, work-generation, work-member, work-taxonomy, work-website-repository-state` — remain pending follow-up sweeps).

2026-05-09 — packages/agent zero-coverage repositories sweep #3 (4 TypeORM repositories — final batch, 143 tests across 4 spec files, #620)

Closes the LAST 4 of the previously-zero-coverage packages/agent/src/database/repositories/*.repository.ts files in the canonical __tests__/<name>.repository.spec.ts layout. All four suites pass via pnpm --filter @ever-works/agent test --testPathPattern=... in 18s, total 143 tests; full agent suite 2292 → 2435 green across 122 → 126 suites. The packages/agent repository zero-coverage gap list is now empty — every .repository.ts file under packages/agent/src/database/repositories/ has a dedicated unit suite. notification.repository.spec.ts (24 tests) — create (every documented field passthrough w/ isPersistent ?? false default + forces isRead:false+isDismissed:false on every new row, explicit isPersistent:false is preserved as false — pinned because a future swap from ?? to || would treat false as missing and silently flip it to the ?? false default which would still be false BUT the test is the regression-guard against a worse swap to ?? true); findByUserId defaults (undismissedOnly:true/limit:50/offset:0, omits unreadOnly/category clauses when default), forwards unreadOnly:true AND undismissedOnly:false AND optional category clauses, ALWAYS adds the (expiresAt IS NULL OR expiresAt > :now) filter using Date.now() snapshot (mocked via jest.spyOn(Date, 'now') so the bigint comparison value is deterministic — the entity stores expiresAt as bigint so a Date object cannot be compared directly, this is the documented portability seam); findById / findByIdAndUserId (composite-key cross-user 404 safety); markAsRead patch shape; markAllAsRead targets ONLY unread+undismissed rows for the user (so markAllAsRead does NOT retroactively un-dismiss previously-dismissed-but-unread rows — pinned via expect(... { userId, isRead:false, isDismissed:false }, ...)); dismiss writes BOTH isDismissed:true AND isRead:true (a dismissed notification is implicitly read); findByDeduplicationKey composite-key lookup; getUnreadCount query-builder w/ four chained andWhere clauses incl. expiresAt-bigint filter; deleteExpired + deleteOlderThan query-builder w/ chained delete().where(...).execute() and affected || 0 coercion (pinned because a future swap to ?? 0 would let null through as null when some drivers report missing affected as null instead of undefined); getPersistentNotifications query-builder w/ persistent+undismissed+non-expired ordered by createdAt:DESC; clearDeduplicationKey writes ONLY isDismissed:true (does NOT touch isRead — asymmetry pin vs dismiss(id) because the dedup-clear path is for silencing duplicate streams without retroactively marking history as read, and the test asserts not.toHaveProperty('isRead') on the partial). activity-log.repository.spec.ts (21 tests) — create/update+findById-refetch (returns null when row vanished); findById + findByIdAndUserId (composite-key cross-user safety, both join ['work']); findLatestByUserWorkActionStatus 4-discriminator lookup w/ DESC + work join (used by reconcileActivities to merge in-progress GENERATION rows so a single user×work×run never produces two rows); findInProgressGenerationsByUserId hard-codes 'generation' + 'in_progress' as string casts to ActivityActionType/ActivityStatus (NOT enum re-imports — pinned because the cast lets a future enum-rename break loudly while the wire value stays stable); findByUserId/findByUserIdForExport query-builder w/ optional filter forwarding (actionType/workId/status/dateFrom/dateTo each fire the documented andWhere clause), search-Brackets registered ONLY when trimmed search non-empty (whitespace-only short-circuits via prepareCaseInsensitiveContainsPattern), search-Brackets composes a LOWER(activity.summary) LIKE :search ESCAPE '\' OR LOWER(work.name) LIKE :search w/ %hello\%world% (escaped % literal preserved + lowercased + wrapped in %...%), findByUserId caps requested limit at 100 via Math.min (boundary 100 stays at 100, 200 capped to 100), falsy limit/0 defaults to 25 via ||, findByUserIdForExport SKIPS the cap (enforceCap:false so 5000 passes through verbatim — used by the controller for the 10 000-row CSV export ceiling), envelope shape {activities, total} from getManyAndCount, the for-export variant unwraps to the activities array; countByStatus composite (userId, status); countByStatuses query-builder w/ groupBy(status), returns the FULL 5-status grid initialised to 0 even when query returns nothing (so the UI can render empty rows w/o defensive ?? 0 checks), coerces string counts via Number(count) || 0 (Postgres returns COUNT(*) as string), unknown statuses are added to the grid as-is (current-behaviour pin: counts[row.status] = ... does NOT pre-validate, so a future strict-validation tightening would be a deliberate change). work-member.repository.spec.ts (29 tests) — addMember forwards 4 args verbatim w/ invitedById:undefined passthrough when omitted (NOT defaulted to null — pinned so a future tightening to ?? null would be a deliberate change); findMember/findById join ['user', 'work', 'invitedBy'] (same three relations on both lookups so callers can pivot between by-id and by-(workId,userId) lookups w/o re-fetching joins); findByWork orders ASC w/ user+invitedBy join (preserves invite order in UI, NOT work join because caller already has it); findByUser orders DESC w/ work+work.user join (newest invites surface first in dashboard); getAccessibleWorkIds select:['workId'] lean projection (skips relations) used by the auth check; isMember count > 0 test; hasRole short-circuits false when no membership exists (skipping the hierarchy check entirely), delegates to member.hasRoleOrHigher(role) w/ MANAGER passes-EDITOR-threshold + VIEWER fails-EDITOR-threshold parametrized matrix; updateRole updates by composite key then refetches via findMember (so post-update relation tree is loaded), null-on-vanished row; removeMember (affected ?? 0) > 0 four-branch matrix (1 → true, 0 → false, undefined → false, null → false — pinned for nullish-coalesce vs || swap regression); removeAllMembers returns affected count w/ ?? 0 coercion of undefined; countMembers workId only; findByRole composite (workId, role) joining ONLY user (NOT work/invitedBy — caller has work; invitedBy is irrelevant for the role filter); findEditableMembers queries In([MANAGER, EDITOR]) (VIEWER intentionally excluded so the per-work edit guard can use this list directly); findManagers single-role query (NOT In(...) for one element — micro-optimisation pinned); getMemberRolesForWorks empty-input short-circuit returns empty Map WITHOUT touching the repository (avoids IN () SQL syntax error), happy path queries by (userId, workId IN [...]) selecting only ['workId', 'role'] and returns Map<workId, role> w/ has(missingId) === false. work.repository.spec.ts (69 tests) — covers all 30+ public methods of the largest repository file in the agent package: create rejects 'Work already exists' when findByOwnerAndSlug returns a row (no save call), happy path runs findByOwnerAndSlug → create → save → findById (refetch joins user); createOrUpdate 3-branch matrix — owned-existing row goes through update, foreign-existing row falls through to create w/ schema-level slug uniqueness as the safety net (current behaviour pin), no-existing row creates fresh; findByOwnerAndSlug ternary owner ? {userId, owner, slug} : {userId, slug} (empty-owner is a valid linked-repo case); findById joins user; findByIds empty-input short-circuit, all-falsy short-circuit, dedupes via new Set(...) (so callers pulling ids from multiple sources don't pay for duplicates), strips falsy ids before deduping; findAll no-options omits where/take/skip/relations-only, userId-only object-form where, search-only triple-OR-array on name/description/slug each w/ a Raw operator (_type:'raw' + _objectLiteralParameters: { search: '%hello\%world%' } — pinned via raw-operator structural inspection), userId+search composes ALL three OR-conditions w/ userId scope, whitespace-only search → falls back to where:{userId} (NOT array form), truthy limit/offset → take/skip; falsy 0/0 → omitted (if (limit) findOptions.take = limit short-circuit pinned because limit:0 is a valid "skip pagination, return everything" signal at the call site that should NOT be confused with default behaviour); countAll mirrors findAll's where shape; update updates-then-refetch via findById, null-on-vanished row; increment positional ({id}, column, value) shape, negative values forwarded verbatim (decrement is just increment(-N)); delete/deleteBySlug affected > 0 (uses > NOT >= so affected===0 is false); exists/existsByUserAndSlug (same composite, just argument order swapped at the call site); countByUserAndWebsiteTemplateId/countByUserAndInheritedWebsiteTemplateSelection (the second uses IsNull() operator — used by the "Cannot archive — N works still use this template" UI gate); findByUser no-relations lean projection; updateLastPullRequest merges new fields onto existing fields (so a data PR update does not erase the main PR), spread-order pin (existing first, new last — last wins for same-key); updateGenerateStatus dedupes warnings via new Set(...) AND sets generationProgressedAt to fresh Date (asserted via before <= timestamp <= after), preserves status verbatim when warnings missing/empty (no defensive warnings:[] injection), passes empty-warnings array through verbatim (current behaviour pin), handles null/undefined w/o crash via optional-chain ?.warnings?.length; recordGenerationStartTime writes BOTH generationStartedAt+generationProgressedAt to the SAME date AND resets generationFinishedAt to null (so a re-run clears prior finish state); recordGenerationFinishTime writes ONLY generationFinishedAt (does NOT touch progressedAt — that is the "stalled-job-detector" knob); getUnfinishedGenerations LessThan(olderThan) AND IsNull(finishedAt); findAllAccessible returns [] for missing-userId (without touching query builder), userId-only path uses simple where (no Brackets), userId+memberWorkIds composes a Brackets w/ userId-OR-membership clauses (driven through a sub-chain to capture inner WHERE/orWHERE shape), search Brackets composes case-insensitive name/description/slug LIKE clauses w/ %foo% pattern (pattern empty for whitespace-only → search Brackets NOT registered), truthy limit/offset → take/skip / falsy → omitted; countAllAccessible mirrors findAllAccessible's Brackets shape; getAccessibleStats returns zero envelope for missing-userId (no query), coerces every aggregate column from string to number via parseInt(_, 10) || 0 (Postgres COUNT/SUM returns string), pins the LIKE-on-simple-json gate string work.generateStatus LIKE '%"status":"generating"%' for the generatingCount column (simple-json portability across SQLite/MySQL/Postgres rules out JSON operators — pinned because a future swap to JSON ->> operator would break SQLite tests in CI), pins the four select column shapes verbatim (totalWorks/totalItems COALESCE/activeWebsites NOT-NULL-and-not-empty/generatingCount LIKE), non-numeric/undefined/null inputs → 0 via || 0; findByIdWithMembers joins ['user', 'members', 'members.user']; feature-flag finders (4 parametrized via it.each) — findWithWebsiteAutoUpdateEnabled/findWithCommunityPrEnabled/findWithComparisonsEnabled/findWithScheduledSourceValidationEnabled each query by their respective boolean flag w/ user joined; countForDetailCacheWarmup uses COALESCE(itemsCount, 0) > 0 so works that never generated items don't warm; findForDetailCacheWarmup orders by updatedAt:DESC then id:ASC for deterministic paging across ties, defaults offset:0 when omitted; findDueSourceValidation uses LessThanOrEqual(new Date()) operator (asserted via structural _value inspection — op._value instanceof Date AND getTime() within before/after window) AND orders nextRunAt:ASC for FIFO settlement; updateSourceValidationRun writes lastRunAt to fresh Date AND nextRunAt verbatim (caller computes the next cadence). Mocking pattern follows the subscription-plan.repository.spec.ts (Pick<Repository, ...>-typed jest.Mocked) so suites execute in <16s total with no TypeORM runtime; query-builder chains driven through a small buildChain<TResult>(terminalName, terminalResolved) helper that records every where/andWhere/orderBy/select/addSelect/leftJoinAndSelect/skip/take/groupBy call and returns itself, then resolves the named terminal with the supplied value — Brackets factories are exercised by passing a sub-chain of jest.fn()s into the captured (brackets as any).whereFactory(subChain) so the inner WHERE/orWHERE shape is captured directly (vs. trying to compare opaque Brackets identity).

2026-05-09 — packages/agent zero-coverage repositories sweep #2 (4 TypeORM repositories, 74 tests across 4 spec files, #618)

Closes 4 more of the previously-zero-coverage packages/agent/src/database/repositories/*.repository.ts files in the canonical __tests__/<name>.repository.spec.ts layout. All four suites pass via pnpm --filter @ever-works/agent jest --testPathPattern=... in 18s, total 74 tests; full agent suite 2218 → 2292 green. work-custom-domain.repository.spec.ts (15 tests) — findByWork orders by createdAt:ASC; findOne (workId, domain) composite-key lookup with happy + null branches; addDomain creates with verified:false + forwards optional provider (and passes provider:undefined when omitted, pinned because the column defaults to null); removeDomain returns (result.affected ?? 0) > 0 for affected:1/0/undefined/null — null-coalescing operator pinned so a future swap to || (which would treat 0 as truthy) breaks loudly; updateVerified and updateProvider patch-shape with verbatim composite key. user.repository.spec.ts (18 tests) — create/findOne(opts)/findByUsername/findByEmail/findById happy + null; update(id, data) updates-by-id-then-refetches via findById (refetch returns null when row vanished — pinned); clearPasswordResetToken writes both passwordResetToken:null AND passwordResetExpires:null on (id, token) composite-key match w/ || coercion of undefined affected to 0; createOrGetLocalUser four-branch matrix — current behaviour pin: undefined GH_OWNER/GIT_NAME causes username = undefined and the documented username.trim() guard CRASHES with TypeError BEFORE the documented 'username or Git name cannot both be empty' error fires (pinned so a future "guard against undefined first" fix is a deliberate change), same TypeError for undefined GIT_EMAIL; whitespace-only env vars hit the documented Error messages; GH_OWNER wins over GIT_NAME when both set; existing-row-by-username-OR-email returns the row with local=true mutated in-place (no defensive copy — pinned via toBe identity); new-user creates with password = randomUUID() (mocked deterministic via node:crypto jest.mock) + emailVerified:true. Env-var setters wrapped in beforeEach reset + afterEach restore so test order does not leak. conversation.repository.spec.ts (19 tests) — create forwards CreateConversationInput verbatim including the userId-only minimal payload; findById queries with relations:['messages'] + order:{messages:{createdAt:'ASC'}} for deterministic message ordering — userId-omitted skips the userId scope, userId-provided adds cross-user safety, empty-string userId is treated as omitted because userId && { userId } short-circuits (pinned because that's the legacy null-equivalent semantic); findByUser defaults limit:50/offset:0, selects the 6 documented projection columns, returns {conversations, total} envelope, limit:0 is forwarded verbatim NOT defaulted because ?? does NOT coerce 0 (pinned current behaviour — a future swap to || would change the boundary); appendMessage saves the message and touches the conversation updatedAt with a fresh Date (asserted via before <= timestamp <= after); appendMessages empty-input short-circuit (no repo calls), sequential save with explicit createdAt = baseTime + i so multi-row appends never share a timestamp (pinned via captured-entity inspection — the source comment says batch save can collapse timestamps and break ORDER BY on reload), conversation updatedAt touched ONCE at the end using the FIRST message's conversationId (pinned because cross-conversation appends are not supported but the source does not assert it — a future explicit-error refactor would change this); updateTitle patches by composite (id, userId) with conditional metadata spread (omitted when undefined, pinned via three-branch test); delete returns (result.affected ?? 0) > 0; deleteAllByUser returns affected count w/ ?? 0 coercion of undefined. template.repository.spec.ts (22 tests) — findById simple where; findVisibleByKind returns built_in PLUS user-owned customs with the OR-array where shape, ordered by sourceType:DESC+name:ASC (so built_in rows come AFTER customs because built_in < custom lexicographically and DESC reverses — pinned for both kind:'website' and kind:'work' literals); findVisibleById mirrors the same OR-array; findOwnedCustomById does NOT filter by isActive (so archived rows are still findable by id when caller has the id — distinct from the visible variants, pinned); findOwnedCustomByRepositoryUrl and findOwnedCustomByRepositoryCoordinates add isActive:true filter; findBuiltInByRepositoryCoordinates orders by id:ASC for deterministic dedup when duplicates exist; hasRecentDiscoveredBuiltInTemplates uses MoreThanOrEqual(updatedSince) (asserted via toEqual(MoreThanOrEqual(since)) structural match) AND a Raw(alias => '${alias} LIKE :ownerMarker', {ownerMarker: '%"discoveredFromOrganization":"<owner>"%'}) operator — the Raw closure has different identity each call so the params object + _type:'raw' tag are pinned instead of closure identity; upsert calls repository.upsert(template, {conflictPaths:['id']}) then refetches via findOneOrFail({where:{id}}) (rejection propagates); updateById does the same update+findOneOrFail pattern (rejection propagates so concurrent deletes surface). Mocking pattern follows subscription-plan.repository.spec.ts (Pick<Repository, ...>-typed jest.Mocked) so suites execute in <16s total with no TypeORM runtime. Closes 4/8 of the remaining agent-package repository zero-coverage gap; remaining 4 (activity-log + notification + work-member + work repositories) deferred to subsequent sweeps.

2026-05-09 — packages/agent zero-coverage repositories sweep (8 TypeORM repositories, 72 tests across 8 spec files, #617)

Closes 8 of the 16 previously-zero-coverage packages/agent/src/database/repositories/*.repository.ts files in the now-canonical __tests__/<name>.repository.spec.ts layout. All eight suites pass via pnpm --filter @ever-works/agent test --testPathPattern=database/repositories/__tests__/... in 19s, total 72 tests; full agent suite 2218/2218 green (was 2146 before this PR). subscription-plan.repository.spec.ts (10 tests) — findAllActive queries with active:true; findByCode forwards SubscriptionPlanCode into the where clause + null branch; upsert three-branch matrix (existing-by-code → update(existing.id, plan) then findOne({where:{id}}) refetch; missing → create(plan) → save; missing-code-in-plan short-circuits the findByCode lookup so callers can upsert({}) without a code). work-advanced-prompts.repository.spec.ts (8 tests) — findByWorkId happy + null; createOrUpdate updates-by-id-then-refetch when row exists / creates with {workId, ...data} spread when missing; delete returns result.affected > 0 truthy/falsy/undefined matrix (pinned so a future swap to affected != null doesn't silently start returning true on undefined). user-template-preference.repository.spec.ts (7 tests) — parametrized findByUserAndKind for both TemplateKind literals ('website'/'work'); upsertDefault pins the TypeORM upsert(...) shape with the conflictPaths: ['userId', 'kind'] composite-key (regression guard so a future swap to findOne+save would lose the upsert atomicity), then refetches via findOneOrFail; refetch-rejection propagates verbatim; deleteByUserKindAndTemplateId forwards the 3-key composite into delete(). user-subscription.repository.spec.ts (10 tests) — findActiveByUser queries by SubscriptionStatus.ACTIVE w/ relations:['plan'] joined; listByUser orders by createdAt:DESC w/ plan join; createOrUpdate updates-active-then-refetch / creates-with-userId path / caller-supplied userId in data is overridden by the userId argument (spread-order pin: {...data, userId} — pinned so a future flip to {userId, ...data} that would let callers spoof userId fails loudly); cancel writes SubscriptionStatus.CANCELED. usage-ledger.repository.spec.ts (8 tests) — record create→save; findPendingByUser orders by createdAt:ASC for FIFO settlement; markQueued returns early on empty ids w/o touching the query builder; non-empty ids trigger the chained createQueryBuilder().update(UsageLedgerEntry).set({status:QUEUED_FOR_SETTLEMENT}).whereInIds(ids).execute() (positional shape pinned via mock-chain forwarding); getUsageSummary selects only units+amountCents columns and reduces them; entry.units || 0 short-circuit handles missing/null fields; zero envelope for empty inputs. webhook-subscription.repository.spec.ts (12 tests) — createForAccount initialises status:'active' + consecutiveFailures:0, workId ?? null coercion for both undefined AND explicit-null (account-wide subscription); listActiveForWork queries via OR-array [{workId, active}, {workId:null, active}] so account-wide subscriptions also match a per-work lookup (regression guard so a future query simplification doesn't break account-wide delivery); markSuccess zeros consecutiveFailures + fresh Date lastDeliveryAt; incrementFailure increments + refetches + returns consecutiveFailures ?? 0 for the row-vanished and undefined-counter branches; markFailed/pause patch shapes; findById happy + null. refresh-token.repository.spec.ts (13 tests) — create/findByToken+relations:['user']/findByUserId (active-only via revoked:false)/findByFamily (revoked-or-not via createdAt:DESC for rotation reuse detection); revokeToken/revokeAllUserTokens/revokeTokenFamily patch-shape pins with the right WHERE clauses (only the family/user variants restrict to revoked:false so already-revoked tokens are NOT touched again, pinned to keep revokedAt stable); deleteExpiredTokens uses LessThan(new Date()) operator (pinned via _value introspection so a future swap to a different operator breaks loudly) and affected || 0 coercion; deleteRevokedTokensOlderThan(cutoff) filters revoked:true AND revokedAt < cutoff. onboarding-request.repository.spec.ts (14 tests) — three findBy* lookups (identity+repo composite / repo-only / id-only); create create+save; tryTransition compare-and-swap with full mock-chain on createQueryBuilder().update().set({status, ...extra}).where('id = :id AND status = :from', {id, from}).execute()affected > 0 returns true; affected:0 returns false (someone else won the race); undefined affected coerces to false via ?? 0 short-circuit; no-extra-arg path emits set with just {status} (pinned because the optional spread MUST be undetectable); markFailure writes {status:'failed', failureCode, failureDetail} w/ failureDetail:undefined passthrough (no defensive coercion to null); setWorkId/setAccountId single-column patches. Mocking pattern follows the existing work-generation-history.repository.spec.ts (Pick<Repository, ...>-typed jest.Mocked) so the suite executes in <2s per file with no TypeORM runtime. Closes 8/16 of the agent-package repository zero-coverage gap; remaining 8 (activity-log + conversation + notification + subscription-plan extension surface + template + user + webhook-subscription extension + work-custom-domain + work-member + work repositories) deferred to subsequent sweeps.

2026-05-09 — apps/api misc DTO coverage sweep (6 DTO surfaces, 145 tests across 6 spec files, #616)

Closes the remaining zero-coverage class-validator DTOs across six different apps/api/src/ modules. All six suites pass via pnpm --filter ever-works-api test --testPathPattern=... in 14s, total 145 tests. plugins-capabilities/deploy/dto/domain.dto.spec.ts (12 tests for AddDomainDto) — accepts simple/multi-label/multi-segment domains via parametrized it.each, @IsString/@IsNotEmpty rejection paths, the canonical regex /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/ rejection paths via it.each (leading-dash, trailing-dash, no-TLD, leading-dot, spaces, underscores, full URL with scheme, single-letter TLD), single-label localhost rejection, and double-dot rejection. The 'Invalid domain format. Example: example.com' literal message is pinned so a future copy change has to be a deliberate update. trigger/dto/remote-call.dto.spec.ts (11 tests for RemoteCallDto) — happy paths for the SuperJSON args: { json, meta? } envelope shape, missing-string rejection (no @IsNotEmpty, so empty-string accepted at DTO layer because the controller's secret-guard surfaces the canonical 401 first), @IsObject rejection of strings AND arrays (class-validator distinguishes plain objects from arrays here — pinned because the trigger handler treats arrays as a regression-relevant attack shape), and arbitrary args.json value passthrough (no nested validation enforced because SuperJSON deserializes downstream). onboarding/dto/register-work.dto.spec.ts (24 tests for RegisterWorkRequestDto) — minimal-valid + fully-populated payloads, repo field with @IsString + @MaxLength(512) + the ^https:\/\/github\.com\/[^/]+\/[^/]+\/?$ regex (case-insensitive — HTTPS:// accepted; trailing-slash accepted; rejects http:// / https://gitlab.com/ / no-scheme / no-repo / double-slash / not-a-URL), optional email @IsEmail, optional agentId @Length(1, 256) + @Matches(PRINTABLE_ASCII) (newline rejected, space at 0x20 rejected because regex starts at 0x21), optional webhookUrl @MaxLength(2048) + ^https?:\/\/.+ regex (http AND https accepted, ftp:// rejected), optional subdomain @Length(3, 63) + ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ regex (uppercase rejected — pinned because DNS labels are case-insensitive but the platform uses lowercase canonical form, leading/trailing dash rejected, underscore/space rejected), optional agentPayment @IsObject. Per-field 'must be …' literal messages are pinned to prevent silent UX drift. template-catalog/dto/list-templates.dto.spec.ts (28 tests across 7 DTOs) — every DTO's kind field uses the SAME @IsIn(['website','work']) constraint with case-sensitivity (uppercase rejected — pinned via 'WEBSITE' test) and out-of-enum rejection; AddCustomTemplateDto repositoryUrl requires @IsUrl({protocols:['http','https'], require_protocol:true}) so github.com/... (no scheme) AND ftp://... are both rejected, optional previewImageUrl shares the same constraints (rejecting data:image/... URIs); UpdateCustomTemplateDto accepts kind-only payload (every other field optional — pinned because PATCH semantics MUST allow clearing one field at a time); ForkTemplateDto requires templateId + targetOwner (both @IsString); SetDefaultTemplateDto requires templateId (the kind+templateId compound key is the per-user default identifier). plugins/dto/update-plugin-settings.dto.spec.ts (29 tests across 7 DTOs) — UpdateUserPluginSettingsDto / UpdateWorkPluginSettingsDto accept empty payload (every field optional under @IsObject, but rejects strings AND arrays — class-validator @IsObject excludes arrays, pinned); EnableUserPluginDto autoEnableForWorks @IsBoolean rejection; EnableWorkPluginDto activeCapability validated via IsValidCapabilityConstraint ('ai-provider' accepted, 'definitely-not-a-capability' rejected with the isValidCapability constraint key — pinned because the constraint name is the regression surface for Reflector.getAllAndOverride lookups), priority @IsNumber + @Min(0) w/ priority:0 boundary acceptance; SetActiveCapabilityDto capability IS required (@IsString failure when missing, distinct from EnableWorkPluginDto.activeCapability which is optional — pinned asymmetry); UpdateWorkPluginPriorityDto priority IS required + @Min(0); SetGlobalPipelineDefaultDto accepts both pluginId: undefined AND pluginId: null (pinned because @IsOptional() short-circuits BOTH null AND undefined — meaning callers CAN clear the global default by sending an explicit null, a future swap to @ValidateIf would start failing this test path), but rejects non-string pluginId value, enforce is REQUIRED (@IsBoolean failure when missing, no @IsOptional). ai-conversation/dto/openai-compat.dto.spec.ts (41 tests across 4 DTOs) — OpenAiFunctionDto name required @IsString, optional description @IsString, parameters/strict arbitrary via @Allow (no nested validation — pinned because the OpenAI tool spec accepts arbitrary JSON Schema in parameters); OpenAiToolDefinitionDto type:'function' @IsString + nested OpenAiFunctionDto validation forwarding (inner @IsString failures propagate via errs.children[0]); OpenAiMessageDto role required @IsString, content: string | null permissive @Allow (so assistant tool-call messages with content:null pass — pinned because that's REQUIRED by OpenAI's wire format), optional name/tool_call_id @IsString, tool_calls array passthrough via @Allow; OpenAiChatCompletionRequestDto permissive parent — messages REQUIRED @IsArray + @ValidateNested w/ inner-message-failure forwarding via errs.children[0].children[0].property === 'role' walk pinned, optional temperature/max_tokens/top_p/frequency_penalty/presence_penalty all @IsNumber, optional stream @IsBoolean, stop @IsArray, tool_choice/response_format/stream_options arbitrary via @Allow (so 'auto' string AND {type:'function', function:{name:'X'}} object both pass — pinned because the OpenAI wire format accepts both shapes), optional user/model @IsString. The DTO surface is intentionally permissive (the controller uses ValidationPipe({whitelist:true}) w/o forbidNonWhitelisted) because OpenAI clients send arbitrary extras like seed/logit_bias/logprobs — pinned via the all-fields-passthrough fully-populated test. Total apps/api/src/ source spec count: 95 → 101.

2026-05-09 — apps/api plugins-capabilities + github-app DTO coverage sweep (4 DTO bundles, 78 tests across 4 spec files, #615)

Adds 78 unit tests across 4 new spec files covering the previously-zero-coverage DTOs in apps/api/src/plugins-capabilities/{deploy,search,screenshot}/dto/ and apps/api/src/integrations/github-app/dto/. Pattern follows works/dto/dto.spec.ts (#506) and the auth-dto sweep (#614) — class-validator.validate(plainToInstance(Dto, plain)) end-to-end with no NestJS context. plugins-capabilities/deploy/dto/dto.spec.ts (22 tests across 5 DTOs): DeployWorkDto (4 tests — optional teamScope @IsString, empty-string accepted because no @IsNotEmpty); ValidateTokenDto (4 tests — providerId @IsString only, undefined fails IsString, empty-string accepted at DTO layer because controller's "Provider 'X' is not available" is the canonical failure surface); GetTeamsDto (3 tests — symmetric pin to ValidateTokenDto so a future "tighten one only" change creates an asymmetry that fails); BatchDeployItemDto (4 tests — workId + optional teamScope @IsString); BatchDeployDto (7 tests — works[] @IsArray + @ValidateNested + @Type(() => BatchDeployItemDto) forwarding pinned via inner-isString error walked through errs.children[0].children, empty works:[] accepted because no @ArrayMinSize so the controller can return a successfullyStarted: 0 envelope, top-level optional teamScope). plugins-capabilities/search/dto/dto.spec.ts (16 tests for SearchDto): query @IsString only (no @IsNotEmpty — pinned because empty queries surface as facade-level errors not 400s), maxResults parametrized boundary tests (1 lower, 50 upper, 0 below-min, 51 above-max), non-numeric maxResults via @IsNumber, includeDomains/excludeDomains @IsArray + @IsString({each:true}) with empty-array acceptance and per-element non-string rejection. plugins-capabilities/screenshot/dto/dto.spec.ts (29 tests for CaptureScreenshotDto + GetScreenshotUrlDto): URL-only happy path + fully-populated payload acceptance, url @IsUrl + missing-url fail, providerOverride @IsString, workId @IsUUID (v4 happy + non-uuid fail), viewportWidth boundaries (@Min(320)/@Max(3840) with both edges + below + above), viewportHeight boundaries (@Min(240)/@Max(2160)), format parametrized happy-path for every documented literal (png/jpg/webp) + unknown rejection + uppercase rejection (case-sensitive @IsIn), four boolean flags (fullPage/blockAds/blockTrackers/blockCookieBanners) parametrized via it.each for @IsBoolean rejection, delay boundaries (@Min(0)/@Max(10000) with both edges + below + above), GetScreenshotUrlDto inheritance pin (class GetScreenshotUrlDto extends CaptureScreenshotDto — same surface, regression guard so a future divergence has to be a deliberate change). integrations/github-app/dto/github-app.dto.spec.ts (16 tests for GitHubAppSetupQueryDto + GitHubAppCallbackQueryDto): setup query — installation_id @IsString only, setup_action @IsIn(['install','request']) case-sensitive (uppercase 'INSTALL' rejected), undefined installation_id fails IsString, empty-string installation_id accepted (controller surfaces missing IDs as BadRequest('GitHub App installation could not be persisted') downstream), optional redirectTo @IsString; callback query — code + state BOTH REQUIRED @IsString (state-required pin documents CSRF protection — a future @IsOptional() would silently bypass HMAC verification in GitHubAppOnboardingService), missing-state fails IsString, empty-string for either field accepted at DTO layer because the handler surfaces them as Unauthorized('OAuth code missing') / Unauthorized('Invalid state') (DTO-layer @IsNotEmpty would convert those into 400s and change the public error contract). All 4 suites pass via Jest in 18s. Total apps/api source spec count: 91 → 95.

2026-05-09 — apps/api auth DTO coverage sweep (10 DTOs, 52 tests in apps/api/src/auth/dto/dto.spec.ts, #614)

Adds 52 unit tests covering ALL 10 previously-zero-coverage class-validator DTOs in apps/api/src/auth/dto/. Follows the works/dto/dto.spec.ts (#506) pattern — class-validator.validate(plainToInstance(Dto, plain)) so both transformer + validator sides execute end-to-end with no NestJS context. RegisterDto (10 tests) — username @MinLength(3) + @IsNotEmpty ordering (empty fails first), email @IsEmail, password 6-char min via @MinLength(6), password @Matches(/^[^.\n](?=.*[a-z])(?=.*[\d\w]).*$/) regex pinned literally (rejects leading dot/newline, requires at least one lowercase letter AND at least one digit/word-char), 6-char boundary acceptance (abc1de), all-uppercase rejection, optional emailVerificationCallbackUrl non-string rejection. LoginDto (4 tests) — email + password @IsNotEmpty, NO password length rule (pinned literally — login MUST accept any pre-existing password regardless of new length policy, otherwise users with shorter old passwords would be silently locked out). UpdatePasswordDto (5 tests) — newPassword @MinLength(8) (stricter than RegisterDto's 6 — pinned as deliberate asymmetry: registration is a lower bar; password rotation is an opportunity to upgrade), @Matches regex same as RegisterDto, current+new same value is NOT rejected at the DTO layer (pinned because that's the auth-provider's bcrypt.compare decision). OAuthCallbackDto (4 tests) — code @IsNotEmpty, optional state @IsString. VerifyEmailDto (3 tests) — token @IsNotEmpty + non-string rejection. ResendVerificationDto (3 tests) — email @IsEmail (operative failure on empty too). ForgotPasswordDto (4 tests) — email + optional resetPasswordCallbackUrl non-string rejection. ResetPasswordDto (4 tests) — token @IsNotEmpty + 8-char-min newPassword + leading-dot rejection (matches UpdatePasswordDto policy). UpdateProfileDto (8 tests) — every field optional (empty payload accepted), username @MinLength(3), avatar @IsUrl, committerEmail @IsEmail, null-passthrough invariant pinned for both committerName AND committerEmail because @IsOptional() (NOT @ValidateIf((_, v) => v !== undefined)) short-circuits BOTH null AND undefined — meaning callers CAN clear these fields by sending an explicit null, and the auth service is responsible for downstream null-handling. A future swap to @ValidateIf would start failing these two tests by surfacing isEmail/isString errors. CreateApiKeyDto (7 tests) — name @IsNotEmpty + @MaxLength(100) with 100-char boundary acceptance, expiresAt @IsDateString (ISO 8601), explicit "does NOT validate expiresAt is in the future" (pinned because the past-vs-future check lives in ApiKeyService.createKey — over-tightening at the DTO layer would break the service-level "expiresAt in the past" rejection test path). Closes the only direct-coverage gap in apps/api/src/auth/dto/. Total apps/api source spec count: 90 → 91.

2026-05-09 — packages/agent small-surface coverage sweep #2 (source-sync-support + active-capabilities util + works-config-sync listener + database-init service, 55 tests across 4 spec files, #613)

Adds 55 unit tests across four previously-zero-coverage packages/agent/src/*.ts files at the leaves of the coverage tree. import/source-sync-support.spec.ts (8 tests) — pins LINKED_WORK_SYNC_UNSUPPORTED_MESSAGE literal copy verbatim ('Linked works use existing repositories directly and cannot be synced from an import source.') so the user-facing error is regression-protected; supportsWorkSourceSync() returns true for the three documented ImportSourceType values (data_repo, awesome_readme, works_config), false for linked/linked_repo/git_template/unknown/empty-string, false for null/undefined/no-arg via the !!sourceType short-circuit guard before the Set lookup, returns boolean (not undefined for nullish input — pinned so a future return SET.has(sourceType) rewrite would be a deliberate change), is case-sensitive ('DATA_REPO'/'Data_Repo' rejected). plugins/utils/__tests__/active-capabilities.util.spec.ts (24 tests covering all 4 functions) — getActiveCapabilities returns array values verbatim w/ insertion order preservation, dedups via new Set(...) keeping first occurrence, strips falsy entries via .filter(Boolean) BEFORE dedup (empty string/null/undefined/0), treats undefined/null activeCapabilities field AND null/undefined record as empty array, returns a NEW array reference (not the entity's underlying array — caller cannot mutate the entity by mutating the returned value); hasActiveCapability true-when-present, false-when-missing/empty/null, case-sensitive, treats falsy entries as absent ('' does NOT match ''); addActiveCapability appends new capability, idempotent (already-present → no duplicate), starts from empty when activeCapabilities=[], strips falsy entries from existing array before appending, returns NEW array (no in-place mutation), preserves insertion order; removeActiveCapability removes named capability, idempotent (missing-capability returns cleaned list), returns empty when removing only entry, removes ALL occurrences via the dedup pass (intra-record duplicates collapse to one entry then filter strips it), case-sensitive, strips falsy during cleanup, returns NEW array. works-config/__tests__/works-config-sync.listener.spec.ts (6 tests) — WorksConfigSyncListener.handleSyncRequested forwards workId/userId/reason from WorksConfigSyncRequestedEvent verbatim into syncService.syncWork({...}), Object.keys regression guard pins the EXACT 3 documented option keys (no extras smuggled from the event onto the call), parametrized happy-path for every documented WorksConfigSyncReason literal (schedule_updated/schedule_cancelled/provider_changed/pipeline_settings_changed), syncWork rejection propagates out of the handler verbatim via rejects.toBe(boom) (pinned because @OnEvent({async: true}) does NOT convert to fire-and-forget — rejection MUST surface for monitoring), resolved sentinel is undefined/Promise, multiple events do not share state across calls. database/__tests__/database-init.service.spec.ts (10 tests) — DatabaseInitService.onModuleInit initialises the DataSource when isInitialized=false; SKIPS initialize() when isInitialized=true (pinned because re-initialising throws CannotConnectAlreadyConnectedError); runs synchronize() ONLY when APP_TYPE === 'cli' (strict equality — pinned via parametrized rejection of 'api'/'web'/'trigger'/'CLI'/'cli '/'clix'); synchronize runs AFTER initialize (order pinned via shared order: string[] array w/ mockImplementation); the synchronize gate is independent of the initialize gate so a CLI consumer that bootstraps its own DataSource still gets the schema sync (current-behaviour pin); does NOT synchronize when APP_TYPE is unset; rethrows initialize() AND synchronize() errors after logging via logger.error('Failed to initialize database', error) (the catch block exists ONLY to log; rethrow is load-bearing for nest start to abort cleanly); logs ONCE per error (no duplicate logger.error calls); env-var setters wrapped in beforeEach/afterEach so test order does not leak. All 4 suites pass via pnpm --filter @ever-works/agent test --testPathPattern=... in 18s. Closes 4 of the smallest leaves of the agent-package coverage tree.

2026-05-09 — packages/agent small-utility coverage sweep (time/text/error/prompt/git-repository/work utils, 70 tests across 6 spec files, #611)

Adds 70 unit tests across six previously-zero-coverage packages/agent/src/utils/* files in the now-canonical __tests__/<name>.spec.ts layout. time.utils.spec.ts (9 tests) — calculateDurationSeconds(start, end) returns 0 for equal Dates, rounds via Math.round (sub-half down → 0, half-and-above up → 1, .499 → 1, .5 → 2), passes whole-second deltas verbatim (90s / 3600s), preserves NEGATIVE values when end < start (no defensive Math.abs — pinned so a future "absorb the sign" refactor breaks loudly), always returns an integer. text.utils.spec.ts (20 tests) — slugifyText lowercases + replaces \s+ with single dash + strips non-\w chars + collapses multi-dash runs via the final /--+/g, '-' pass; trims leading/trailing whitespace; preserves digits and underscores (within \w); strips accents via NFKD normalisation (Cafécafe, naïve résuménaive-resume); empty string → empty string; all-special-chars → empty string; tab and newline whitespace handled via \s+; accepts non-string input via the explicit .toString() chain (numbers coerced — 123'123'); plus unSlugifyText Title-Case mapping (hello-worldHello World, three-segment slugs, digit preservation, underscore preservation since only dashes are replaced with spaces, lowercase-rest-of-word for HELLO-WORLDHello World), single-token round-trip via slugify→unSlugify, and an empty-string handler. error.util.spec.ts (12 tests) — getErrorMessage returns Error.message verbatim for Error/TypeError/RangeError/custom-Error subclasses; empty-message Error returns ''; non-Error inputs go through String(...) coercion (42 → '42', booleans, null → 'null', undefined → 'undefined', plain objects → '[object Object]' per JS spec, arrays → comma-joined, custom toString() honored); plus getErrorStack returning Error.stack for instances and undefined for ALL non-Error inputs (including objects with a .stack property — pinned because the function uses instanceof Error not duck-typing), Error explicitly stripped of stack returns undefined. prompt.util.spec.ts (10 tests) — appendCustomPrompt returns the base prompt verbatim for undefined/null/''/whitespace-only customPrompt (the .trim().length === 0 guard prevents stray "Additional User Instructions" headers with empty bodies); appends the custom prompt under the literal ## Additional User Instructions: header (pinned literally so a future header-rename breaks loudly); trims the custom prompt before appending but preserves internal whitespace verbatim; separates with exactly one blank line (\n\n literal pinned via split-length check); empty base prompt still emits the header before custom text; does NOT collapse internal whitespace runs (e.g. 'a b' survives). git-repository.utils.spec.ts (10 tests) — assertCreatedRepositoryTarget(repo, owner, name, label) returns the repo verbatim (identity, toBe not toEqual) when both fields match; throws Error on owner-mismatch / name-mismatch / both-mismatch; error message contains the contextLabel verbatim, the actual fullName from the createdRepository, the expected owner/name form, AND the user-facing diagnosis copy 'This usually means the connected Git account does not match the work owner.' (pinned literally because operators see this message when a deploy lands in the wrong account); equality is case-sensitive ('Alice' does not match 'alice' — pinned so case-normalisation is a deliberate change). work.utils.spec.ts (8 tests) — getWorkOwner(work) returns work.user reference verbatim when valid; throws Error w/ message Work owner not loaded for work <id>. Ensure the user relation is joined. for undefined/null/missing-id/non-string-id work.user; error message includes the offending work id for log triage; returns the SAME owner object reference (no defensive copy); empty-string id is still typeof 'string' so it currently passes — pinned the current behaviour so a future tightening to !owner.id (which would also reject '') is a deliberate change. All six suites pass via pnpm --filter @ever-works/agent test --testPathPattern=... in 14s. Closes the smallest leaves of the packages/agent/src/utils/ zero-coverage list — bringing the agent-package surface that has direct unit-test coverage one step closer to 100%.

2026-05-09 — apps/api small-utility coverage sweep (capability validator + work-cache constants + auth decorators + github-scopes re-export, 84 tests across 5 spec files, #609)

Adds 84 unit tests across five previously-zero-coverage apps/api/src/ files — all small-utility surfaces that were missed by the controller/service-level sweeps. plugins/dto/validators/capability.validator.spec.ts (33 tests) — IsValidCapabilityConstraint parametrized happy-path for every documented PLUGIN_CAPABILITIES value (13 entries), unknown-string rejection, empty-string rejection, non-string rejection (null/undefined/numbers/booleans/objects/arrays), defaultMessage interpolates the offending value verbatim, ends with Valid capabilities are: ${ALL_PLUGIN_CAPABILITIES.join(', ')} (literal-tail check), template-literal coercion for non-string values (null → 'null', undefined → 'undefined', 42 → '42'); @IsValidCapability() end-to-end via validate(plainToInstance(Dto, plain)) for every documented capability + unknown-string + non-string + null/undefined + caller-provided message override + errors[0].constraints keyed under 'isValidCapability' (regression guard so a future rename of the name option breaks loudly); barrel re-exports (isValidCapability is the same function reference as isValidPluginCapability via toBe identity check, getValidCapabilities() returns the same array reference as ALL_PLUGIN_CAPABILITIES (no defensive copy), length matches the documented surface). works/work-cache.constants.spec.ts (22 tests) — pins the four cache-key prefix literals (work-config-/work-count-/work-items-/work-categories-tags-) because they are referenced from cacheEntryRepository.typeormAdapter.deleteUnscopedEntriesLike for prefix-based invalidation in updateWebsiteSettings (a silent rename would break invalidation), every prefix ends with a single trailing dash, all four prefixes mutually distinct; WORK_CACHE_TTL_MS is exactly 1000 * 60 * 10 (== 600 000) and a positive integer; per-builder verbatim-output (getWorkConfigCacheKey('w1', 'u1')'work-config-w1-u1'), no URL-encoding (special chars passthrough), empty-string tolerance (no throw), prefix-startsWith invariant on every builder; cross-builder invariants — different workIds produce different keys for the same user, different userIds produce different keys for the same work (no per-user cross-leak), all four builders produce mutually distinct keys for the same (workId, userId) (Set-size 4). auth/decorators/public.decorator.spec.ts (5 tests) — IS_PUBLIC_KEY is the literal 'isPublic' (pinned because AuthSessionGuard reads via Reflector.getAllAndOverride(IS_PUBLIC_KEY, ...) — a silent rename would make every @Public() route fall through to auth checks); @Public() attaches { isPublic: true } to the decorated handler via Reflect.getMetadata; class-level @Public() attaches the same metadata to the class constructor; non-decorated handlers do NOT have the metadata key (negative path for guard reflection); Public() is a zero-arg factory returning a method/class decorator. auth/decorators/user.decorator.spec.ts (5 tests) — pulls the captured factory off Nest's __routeArguments__ metadata (the documented unwrap pattern for createParamDecorator) and exercises it directly with a synthesised ExecutionContext stub: returns request.user reference verbatim from the HTTP request object, returns undefined when request.user is not set (unauthenticated context), ignores the data argument (no key-path projection — passing 'userId' does NOT return only the userId, pinned so a future "support sub-key access" refactor breaks loudly), returns falsy primitives verbatim (no defensive coercion: null/0/''/false all preserved), uses ctx.switchToHttp().getRequest() exactly once (asserted via jest.fn() invocation count — pins HTTP-only support, no GraphQL/RPC fallback). auth/config/github-scopes.config.spec.ts (6 tests) — GITHUB_SCOPES is the same array reference as @ever-works/plugin#GITHUB_SCOPES (toBe identity check, NOT toEqual — pins that the file forwards the same array reference, no defensive copy), is a non-empty readonly array of non-empty strings, contains the four core scopes the auth flow + GitHub plugin rely on (user:email for resolveGitHubAccountEmail fallback, read:user for basic profile, repo for full repo access, workflow for .github/workflows deploys — pinned literally so a silent removal would break the auth + deploy paths), all entries are unique, matches the plugin-package source-of-truth element-for-element (same order via [...] spread). All five suites pass under apps/api/src Jest in 17s on first run. Closes five apps/api/src/*.ts files at the leaf of the test-coverage tree: the capability validator (used by every plugin DTO accepting an activeCapability field), the work-cache constant builders (consumed by every works.controller.ts cacheManager.wrap call site), the two auth decorators (consumed by every controller that uses @Public() or @CurrentUser()), and the GitHub-scopes re-export (consumed by social-auth.providers.ts).

2026-05-09 — apps/api auth/providers/auth-runtime.instance unit suite (#607)

Adds 47 unit tests in apps/api/src/auth/providers/auth-runtime.instance.spec.ts for createAuthRuntimeInstance, the Better Auth factory consumed by AuthModule (AUTH_RUNTIME_INSTANCE provider, line 31). Mocks better-auth so betterAuth(options) captures its single argument, then asserts the option-tree shape across every supported TypeORM driver, the bcrypt hash/verify wiring, the databaseHooks.user.create.before invariant, the conditional socialProviders registration, and the getTrustedOrigins env parse. Initialization guard (2 tests) — throws verbatim 'Auth runtime requires an initialized TypeORM DataSource.' when dataSource.isInitialized === false (no betterAuth(...) call), happy path forwards the captured options object verbatim and returns the betterAuth(...) sentinel. Database client resolution (8 tests) — better-sqlite3driver.databaseConnection, postgresdriver.master, mysqldriver.pool, mariadbdriver.pool; each driver missing the expected field throws 'Unable to resolve Better Auth database client from initialized TypeORM driver "<type>".'; unsupported driver type (e.g. 'oracle') throws the same template w/o calling betterAuth(...). baseURL + basePath (4 tests) — AUTH_URL env var verbatim wins over PORT; without AUTH_URL falls back to http://localhost:${PORT}${AUTH_RUNTIME_BASE_PATH}; without PORT defaults to 3100; basePath is always pinned to the AUTH_RUNTIME_BASE_PATH literal /api/internal/auth-runtime (regression guard so a future rename to e.g. /api/auth/runtime breaks loudly). secret (2 tests) — delegates to config.auth.secret() (reads AUTH_SECRET env var), lets the 'AUTH_SECRET environment variable is required' error propagate out of createAuthRuntimeInstance rather than swallowing it. trustedOrigins (6 tests) — always includes config.webAppUrl() (defaults to http://localhost:3000); WEB_URL env var passthrough; ALLOWED_ORIGINS parsed as comma-separated list w/ per-entry .trim(); empty entries ('' and whitespace-only) filtered before adding; deduplicates webAppUrl-already-in-ALLOWED_ORIGINS via Set semantics (one occurrence pinned via .filter(o => o === url).length); empty ALLOWED_ORIGINS string short-circuits to a single-element list. advanced.database (1 test) — generateId === 'uuid' so Better Auth issues UUIDs not nanoids (matches the id PK type used across users/auth_sessions/auth_accounts). user model (4 tests) — modelName: 'users', fields rename name → username + image → avatar (project schema mapping), full additionalFields shape (password/registrationProvider/isActive/lastLoginAt/lastLoginIp/committerName/committerEmail — all input:false so Better Auth-side create cannot accept them from request bodies; registrationProvider defaults to RegistrationProvider.LOCAL='local'; isActive defaults to true); regression guard on the exact set of seven keys so a silent addition has to be a deliberate update. account.accountLinking (1 test) — enabled:true w/ the four documented trustedProviders: ['google','github','facebook','linkedin'] array literal. emailAndPassword (4 tests) — enabled:true + autoSignIn:true + minPasswordLength:8; password.hash(plain) calls bcrypt.hash(plain, 10) and returns its result (salt rounds = 10 pinned); password.verify({hash, password}) calls bcrypt.compare(password, hash) w/ the (password, hash) positional order (NOT (hash, password) — pinned because the destructured argument order is the easy regression to introduce); verify returns false when bcrypt.compare reports a mismatch. databaseHooks.user.create.before (3 tests) — enriches the create payload with password = bcrypt.hash(randomUUID(), 10) + registrationProvider: LOCAL + isActive: true (every Better Auth-side users.create row is born with a synthetic-UUID password the user can never know — they MUST go through forgotPassword to set a real one); spread order means caller-supplied password/registrationProvider/isActive are always overridden by the synthetic values (defence in depth — pinned with explicit caller-supplied values that DO NOT survive into payload.data); fresh randomUUID() per invocation (no module-level caching) — asserted via randomUUID.mockReturnValueOnce(uuidA).mockReturnValueOnce(uuidB) and verifying bcrypt.hash was called with uuidA then uuidB. socialProviders (8 tests) — empty {} when no env vars set; AND-gate: each provider registers iff BOTH <KEY>_CLIENT_ID AND <KEY>_CLIENT_SECRET are set (id-only and secret-only branches both pinned to omit), all four providers register concurrently when every pair is set; github reads from GH_CLIENT_ID/GH_CLIENT_SECRET (NOT GITHUB_CLIENT_ID — pinned via a setting-GITHUB_CLIENT_ID regression guard that asserts the registration is still skipped); empty-string env values treated as missing (falsy short-circuit). plugins (2 tests) — exactly one entry, the result of calling bearer() (zero-arg) — pinned via mock identity check + Array.isArray + length:1 so a future plugin addition has to be a deliberate registry update. captured options shape regression guard (1 test) — pins the exact set of 12 top-level keys (account/advanced/basePath/baseURL/database/databaseHooks/emailAndPassword/plugins/secret/socialProviders/trustedOrigins/user) so a silent addition or removal in a future Better Auth upgrade breaks loudly. Mocks better-auth (betterAuth captures arg + returns sentinel), better-auth/plugins (bearer() returns sentinel), bcrypt (hash/compare are jest.fn()), and node:crypto.randomUUID so synthetic-password derivation is deterministic. Env-var setters wrapped in beforeEach reset + afterAll full-restore so test order does not leak. Total apps/api/src/auth/providers/ test suites: 4 → 5 (was 4 with auth-provider.service.spec.ts 39 + auth-sync.service.spec.ts 12 + request-headers.spec.ts 19 + this run's 47 = 117 tests across 4 suites). Closes the only remaining direct-coverage gap in apps/api/src/auth/providers/ — every *.ts file under that directory except the type-only auth-provider.abstract.ts/auth-provider.constants.ts/auth-provider.types.ts now has a unit suite.

2026-05-09 — apps/api auth/config/social-auth.providers registry unit suite (#605)

Adds 43 unit tests in apps/api/src/auth/config/social-auth.providers.spec.ts pinning the OAuth provider registry table that powers SocialAuthService. The registry was previously exercised only indirectly through the service-level suite (#496-era); this direct suite catches drift between the registry shape and the four providers it must expose. Registry shape (4 tests) — exactly the four documented keys (github/google/facebook/linkedin), each entry's id field matches its registry key (no key/id drift), every provider exposes callbackUrl/clientId/clientSecret as lazy getter functions (so env vars are read at call time, not at module load), every provider has https://-prefixed authorizationUrl/tokenUrl and a non-empty displayName, every provider has a non-empty scopes[] of non-empty strings. Per-provider (24 tests across 4 providers): GitHub uses https://github.com/login/oauth/{authorize,access_token} + displayName: 'GitHub', mirrors [...GITHUB_SCOPES] (preserving order) BUT owns its own copy via the spread (regression guard so a future direct-reference refactor doesn't accidentally let registry consumers mutate GITHUB_SCOPES), omits scopeSeparator (defaults to space at the call site), reads GH_CLIENT_ID/GH_CLIENT_SECRET lazily (env-set vs env-deleted both pinned), honors GH_CALLBACK_URL override on callbackUrl(). Google uses https://accounts.google.com/o/oauth2/v2/auth + https://oauth2.googleapis.com/token, declares [openid, email, profile] (in order), omits scopeSeparator, reads GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET lazily. Facebook uses v23.0 URLs (https://www.facebook.com/v23.0/dialog/oauth + https://graph.facebook.com/v23.0/oauth/access_token), declares [email, public_profile], uses scopeSeparator: ',' (the only provider that joins scopes with , instead of ), reads FACEBOOK_CLIENT_ID/FACEBOOK_CLIENT_SECRET lazily. LinkedIn uses https://www.linkedin.com/oauth/v2/{authorization,accessToken}, declares [openid, profile, email], omits scopeSeparator, reads LINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRET lazily. getSocialAuthProviderConfig (15 tests) — parametrized happy-path returns the exact registry object reference (toBe, not toEqual) for every documented provider AND the entry id matches the requested id; parametrized error path throws BadRequestException with the verbatim Unsupported OAuth provider: <id> message for 'discord'/'apple'/'unknown'/''/'GITHUB' (case-sensitive — uppercase rejected)/'github ' (trailing-whitespace rejected); explicitly rejects 'github-app' (the GitHub App OAuth flow under apps/api/src/integrations/github-app is NOT a social-auth registry key — pin so a future "consolidate the two flows" refactor breaks loudly). All env-var setters are wrapped in try/finally blocks that restore the original env state regardless of test outcome. Closes the only direct-coverage gap in apps/api/src/auth/config/.

2026-05-09 — apps/api plugins-capabilities/deploy/tasks/deployment-verifier.service unit suite (#604)

Adds 25 unit tests in apps/api/src/plugins-capabilities/deploy/tasks/deployment-verifier.service.spec.ts covering all four public/private surfaces of the only setInterval-driven background task in apps/api. Polling timing is deterministic via jest.useFakeTimers() + jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS) so awaited deployFacade.lookupExistingDeployment + chained repository.update microtasks flush between ticks (legacy advanceTimersByTime does NOT do that). startVerification initial side effects (3 tests) — synchronous repository write {deploymentStartedAt: new Date(now), deploymentState:'INITIALIZING'} + queue insertion under work.id + cancel-on-replace semantics (a second startVerification for the same workId invokes the prior cancel closure → cleanup('CANCELED')DeploymentFailedEvent w/ terminalState:'CANCELED'); fresh-workId path does NOT invoke any prior cancel (regression guard for cross-work bleed). verifyDeployment interval poll outcomes (9 tests) — every documented terminal state with the exact payload shape: READY → repository.update(work.id, {website}) followed by {deploymentState:'READY'} then DeploymentCompletedEvent w/ {userId, providerId, providerName, url}; ERROR → DeploymentFailedEvent w/ terminalState:'ERROR'; CANCELED-from-provider → terminalState:'CANCELED'; not-found path increments fetchTries and once fetchTries > FETCH_LIMIT (=18) cleans up with TIMEOUT; intermediate states (BUILDING/QUEUED) update deploymentState in place but DO NOT terminate (queue entry retained); lookup throwing Error → terminalState:'ERROR' w/ error: error.message; lookup throwing non-Error coerced via String() (e.g. "weird-string-error"); overlapping-tick guard via inVerification flag (second tick fires while first lookup is still in-flight → only one lookupExistingDeployment call); wall-clock TIMEOUT triggers when elapsed > 13 minutes (84 ticks × 10s) even with continuous BUILDING. cleanup terminated-guard idempotency (1 test) — after a terminal state, invoking the original cancel closure again is a no-op: no duplicate event emit + the documented 'Skipping duplicate cleanup' debug log fires (regression guard for the explicit non-conflicting-event invariant called out in the source). emitTerminalEvent provider-name resolution (6 tests) — plugin.providerName ?? plugin.name ?? providerId fallback chain pinned with one test per branch (providerName-set → 'Vercel'; providerName-missing-but-name-set → 'vercel-internal'; both-missing → 'vercel'; pluginRegistry.get returns null → 'vercel'); silent return when work has no deployProvider (no event emitted, but the cleanup repository.update still runs); fallthrough terminalState for non-(ERROR|TIMEOUT|CANCELED) states maps to 'UNKNOWN' — exercised by calling the private emitTerminalEvent directly with 'BUILDING'. lookupExistingDeployment standalone passthrough (6 tests) — facade positional (work.getWebsiteRepo(), {userId, workId}); found+website+deploymentState → repository update with both fields; found+website-only → falls back to existing work.deploymentState; found+deploymentState-only → website: undefined; found-but-NEITHER → no repository update (asserted via not.toHaveBeenCalled()); found=false → no update + verbatim envelope return; facade reject → swallows error + logs + returns {found:false} (defensive — never propagates the rejection). Mocks @ever-works/agent/{database,entities,plugins,facades,events} with empty class stubs; DeploymentCompletedEvent/DeploymentFailedEvent are stubbed with capturing constructors so (event as any).payload is asserted directly without pulling the real entity tree. Closes the T-OQ-3 follow-up flagged in docs/specs/features/plugins-capabilities/tasks.md — the verifier still runs in-process (no Trigger.dev migration yet, so an API restart still drops in-flight verifications), but the unit suite now pins every state transition + idempotency guard so a future Trigger.dev port has a green-field reference.

2026-05-09 — apps/api api.controller + logging.interceptor unit suites (#603)

Adds 20 unit tests across two new spec files closing the last apps/api/src/*.ts files at the repo root with no unit suite. api.controller.spec.ts (8 tests) for APIController: success envelope shape ({status:'success', message:'API is up and running'}); analyticsService.track('anonymous', 'api_home_visit', {endpoint:'/', timestamp}) emission with anonymous distinct id and ISO-8601 timestamp formatted via jest.useFakeTimers(); current "track-throws-propagates" behaviour pinned (the controller does not wrap track() in try/catch, so a future fire-and-forget refactor breaks loudly — flagged in the spec comment); no userId/email leak in payload; healthCheck() delegates to home() and emits the same analytics event (Sentry/PostHog noise filtering happens at the monitoring-package layer, not in this controller). Mocks ./auth (Public decorator) + @ever-works/monitoring so the real PostHog client is not constructed. logging.interceptor.spec.ts (12 tests) for LoggingInterceptor: debug-off short-circuit on unset HTTP_DEBUG, literal 'false', AND '1' (only literal 'true' enables — pins the strict-equal check process.env.HTTP_DEBUG === 'true'); debug-on success path emits Incoming Request: <method> <url> synchronously inside intercept BEFORE subscription, then Outgoing Response: <method> <url> <statusCode> - <delay>ms with statusCode-200 fallback when response.statusCode is falsy; debug-on error path's three-tier statusCode fallback (err.response.statusCodeerr.response={} falls back to 400 → no err.response falls back to 500); elapsed-ms latency via paired Date.now() (verified with fake timers — 250ms after advancing the clock between handle() and tap()/catchError()); Incoming-Request line still emitted before the error fires; original error rethrown via throwError(() => err)rejects.toBe(err) identity preservation pinned; cross-exclusion: success path never calls logger.error, error path never logs the Outgoing-Response line. Stubs the underlying logger via jest.spyOn on the private logger field.

2026-05-09 — apps/api activity-log.controller unit suite (#602)

Adds 33 unit tests in apps/api/src/activity-log/activity-log.controller.spec.ts covering all five endpoints (getActivities/getRunningCount/getSummary/exportCsv/getActivity) plus the private reconcileActivities helper observable through the endpoints — closes the only remaining gap in the activity-log module (listener + Jitsu service had shipped under #482 but the controller itself was untested). Pins: in-flight Map per-user dedup (two concurrent callers run reconcile exactly once), ACTIVITY_RECONCILE_TTL_MS = 5000 cache (call within window skips, call past 5.001s retries), reconcile rejection swallowed AND completedAt NOT cached so the next call retries (vs. successful runs that ARE cached for 5s), per-user isolation, reconcile-before-service ordering pinned via shared order array on every endpoint; getActivities query forwarding (every filter forwarded, ISO dateFrom/dateTo parsed to Date, Math.min(limit, 100) cap incl. boundary limit=100, {activities, total} envelope shape so service-side extras don't leak); getRunningCount {count} envelope; getSummary {counts} envelope; exportCsv filter forwarding + Content-Type: text/csv and Content-Disposition: attachment; filename=activity-log.csv headers + body via res.send; getActivity NotFoundException on null lookup (cross-user safety via composite-key findByIdAndUserId), details ?? {} default coercion, live-logs override behaviour (status==='in_progress' AND workId AND work.generateStatus.recentLogs.length > 0 → override; otherwise preserve activity.details.liveLogs).

2026-05-08 — apps/api plugins-capabilities/deploy/deploy.controller unit suite (T-DEPLOY-CTRL from plugins-capabilities Spec Kit feature, #600)

Adds 54 unit tests in apps/api/src/plugins-capabilities/deploy/deploy.controller.spec.ts covering all 14 DeployController endpoints across 13 describe-blocks — closes the only remaining apps/api/src/plugins-capabilities/ controller without dedicated unit-test coverage (until now only DeployService had unit coverage). listProviders (2 tests) — forwards auth.userId to deployFacade.getAvailableProvidersForUser, success envelope shape {status:'success', providers}, empty-list passthrough. isProviderConfigured (4 tests) — four-branch envelope matrix: provider-not-in-list → available:false w/ "Provider 'X' is not available"; exists-but-disabled → enabled:false w/ "Provider 'X' is not enabled"; enabled+configured → "Provider 'X' is configured."; enabled-but-unconfigured → "Provider 'X' is available but not configured." isProviderConfigured only invoked on the enabled-and-available branch (asserted via not.toHaveBeenCalled() on the short-circuit branches). deploy (8 tests) — full ensureCanEdit → isConfigured → validateToken → deploy → startVerification → log chain with order pinned via shared order: string[] array (each step's mock pushes a label). Caller-vs-creator userId switching: creator path uses auth.userId for facade calls; shared path falls back to work.user.id for isConfigured/validateToken/deployService.deploy/startVerification — but the activity-log payload's userId is ALWAYS auth.userId (pinned via expect.objectContaining({userId: 'caller-1'}) even when deploy runs against owner-1). Error variants: provider-token-required (creator), 'The work owner has not configured Vercel credentials.' (shared), unknown-deployProvider-id falls back to literal id in error message via getProviderName map miss, undefined deployProvider uses 'Deployment' label without consulting getAvailableProviders (asserted via not.toHaveBeenCalled()), invalid-token rejection short-circuits before deploy/verify/log, failed-to-initiate (deployService returns falsy) short-circuits before verify/log w/ "Failed to initiate Vercel deployment. Check that the repository has the provider workflow configured." Activity-log fire-and-forget rejection swallowed via .catch(() => {}) — pinned by mocking log.mockRejectedValue and asserting the response still resolves to {status:'pending', ...}. Happy-path response shape pinned: {status:'pending', slug, owner: work.getRepoOwner('website'), repository: '<owner>/<getWebsiteRepo()>', message:'Deployment started'}. validateToken (3 tests) — AND-gate of enabled && configured flags from getAvailableProvidersForUser: at-least-one-match → valid:true w/ "Deployment provider is available. Token will be validated during deployment."; mixed enabled+configured-on-different-providers → valid:false; empty list → valid:false. getDeploymentTeams (1 test) — pins the placeholder envelope (teams:[] + 'To fetch teams, use the work-specific endpoint or configure your token in Plugin Settings.'), endpoint is awaiting work-context migration. getTeamsForWork (4 tests) — forwards effective userId (creator/shared) to getTeams, error wrapping w/ verbatim error.message AND the provider-name-suffixed 'Failed to get teams. Please configure your <Provider> token in Plugin Settings.' fallback when error has no .message (asserted via mockRejectedValue({})). checkDeploymentCapability (2 tests) — three sequential isConfigured calls (canDeploy w/ effective userId, ownerHasToken w/ owner.id, userHasToken w/ caller.id), isShared = !isCreator, full envelope shape {status:'success', canDeploy, isShared, ownerHasToken, userHasToken}. lookupExistingDeployment (5 tests) — short-circuits with found:true when work.website populated (NO facade/verifier calls — pinned via not.toHaveBeenCalled()); without website: getProviderName is computed UNCONDITIONALLY (pinned via dedicated getAvailableProviders.mockReturnValue([...]) setup on every test even on the happy path), creator-no-token 'Vercel token is required to lookup deployments. Configure it in Plugin Settings.', shared-no-token 'The work owner has not configured Vercel credentials.', happy path forwards verifier result fields verbatim ({website, deploymentState, found}), owner-userId fallback for verifier when shared. batchDeploy (6 tests) — ensureCanEdit fires for EVERY item BEFORE deployBatch (order pinned via shared order: string[]), deployService.deployBatch(works, auth.userId, teamScope) positional shape, status-coercion matrix (failed===0 → success / successfullyStarted>0 → partial / else → error), startVerification fires ONLY for results with status === 'pending' AND truthy workId (defensive guard pinned: workId:undefined + status:'pending' → no verifier call), BATCH activity log shape {action:'deployment.batch_started', summary:'Triggered batch deploy for N works', details:{workIds: [...]}}, log-rejection swallowed via .catch(() => {}) (response still resolves). listDomains / addDomain / removeDomain / verifyDomain (5 + 4 + 4 + 4 tests) — shared !work.website BadRequest gate ('No deployment exists for this work. Deploy first before managing domains.' for list/remove/verify; '…before adding domains.' for add — pinned via two distinct fallback strings); facade positional args (domain, {userId, workId}); creator-vs-shared userId fallback consistent with deploy; verbatim error.message AND fallback string per endpoint ('Failed to get domains' / 'Failed to add domain' / 'Failed to remove domain' / 'Failed to verify domain'); response envelope per endpoint ({status, domains} / {status, ...result} / {status, removed: boolean} / {status, domain: result}). Mocks @ever-works/agent/{database,entities,plugins,facades,services,activity-log,generators,events} plus ../../auth so the agent runtime tree is not pulled in; ActivityActionType.DEPLOYMENT='deployment' and ActivityStatus.COMPLETED='completed' enum-stubs let the activity-log payload be asserted literally. Closes the T-DEPLOY-CTRL row from docs/specs/features/plugins-capabilities/tasks.md follow-up list — every controller in apps/api/src/plugins-capabilities/ now has dedicated unit-test coverage.

2026-05-08 — apps/api auth/providers/* unit suites (T34–T35 from auth-jwt-oauth Spec Kit feature, #599)

Adds 70 unit tests across three previously-zero-coverage apps/api/src/auth/providers/ files — closes the AuthProvider implementation coverage gap called out as outstanding follow-ups T34 and T35 in docs/specs/features/auth-jwt-oauth/tasks.md. auth-provider.service.spec.ts (39 tests) pins the most security-sensitive non-guard surface in the platform — bearer-token path of authenticate (auth_session lookup w/ {where:{token}} shape, expiry deletes the row + returns null without consulting Better Auth, valid session hydrates AuthenticatedUser from the User row with provider:registrationProvider||'local' fallback + falsy-avatar→null coercion + iss:'auth-runtime'/aud:'ever-works-users' invariants); Better Auth cookie path (delegates to auth.api.getSession({headers}), hydrates from session.user w/ name → username + image → avatar mapping, isActive:false triggers signOutAll(user.id) + UnauthorizedException('User account is suspended'), isActive:undefined is treated as ACTIVE — only strict-false trips the suspended branch); getBearerToken private branches via authenticate (case-insensitive bearer/Bearer scheme matching, non-bearer schemes like Basic … fall through to cookie path, empty token after split returns null, missing-Authorization returns null, surrounding whitespace handling); signInEmail ordering pinned via shared order: string[] array — ensureCredentialAccount fires BEFORE auth.api.signInEmail so a pre-existing local password is mirrored into the Better Auth credential account WITHOUT requiring a re-signup, password mirror skipped on social-only users (existing.password=null) AND non-existent users (findByEmail=null), post-sign-in userRepository.update(id, {password, lastLoginAt:new Date(), registrationProvider:'local'}) ONLY when getCredentialPasswordHash returns a hash, 'Failed to establish authenticated session' 401 when Better Auth returns no token (post-assertActiveUser), suspended user trips signOutAll AFTER Better Auth sign-in completes, auth.api.signInEmail invocation shape {headers, body:{email, password, rememberMe:true}} pinned; signUpEmail token-vs-no-token branches (token → direct envelope reusing assertActiveUser; no token → falls through to issueSession(user.id) for verification-required flows), post-sign-up update writes password+registrationProvider:'local'+isActive:true (the explicit isActive:true differentiates it from the sign-in update which omits the field); issueSession 7-day TTL (asserted via Math.abs(expiresAt - now-7d) < 60_000), ipAddress+userAgent initialised to null, randomBytes(24).toString('base64url') token w/ a fresh value per call, randomUUID() id; changePassword no-credential rejection w/ 'Password login is not configured for this account', bcrypt mismatch w/ 'Current password is incorrect', success path runs setPassword which writes the hashed password to BOTH authSyncService.syncCredentialPassword AND userRepository.update; setPassword hashes the plaintext via the runtime password.hash context (resolved lazily via auth.$context); signOut bearer-vs-cookie branching (deletes session row + does NOT call into Better Auth when bearer present, delegates to auth.api.signOut({headers}) otherwise); signOutAll deletes every session row for the user via getSessionRepository().delete({userId}); the requireAuthenticatedUser private branches (invalid bearer → 401 'Invalid session', expired bearer → 401 'Session expired' + delete, missing cookie session → 401 'Missing session token', suspended cookie user → 401 + signOutAll). auth-sync.service.spec.ts (12 tests) pins the credential-account upsert helper used by the Better Auth password-mirror flow: findCredentialAccount queries the AuthAccount repository with {where:{userId, providerId:'credential'}} shape; ensureCredentialAccount short-circuits without writing when an existing row is present (returns the row reference verbatim); new-row create uses userId for BOTH userId AND accountId fields (pinned because the dual-purpose semantics is non-obvious — accountId is the per-provider opaque user identifier, which equals userId only for the local credential provider), randomUUID() generates a fresh id per call (asserted via two-call distinctness check); syncCredentialPassword falls through to ensureCredentialAccount when no row exists OR calls repository.update(id, {password}) ONLY (no other columns touched, asserted via Object.keys(partial) regression guard) when one does; getCredentialPasswordHash returns the hash on hit, null on missing row, null on null/empty-string password (the account?.password || null collapse documented for both falsy values). request-headers.spec.ts (19 tests) pins the toHeaders helper used by every controller call site to convert Express-style header shapes into the WHATWG Headers interface required by Better Auth — Headers passthrough w/ defensive copy (mutating the result does NOT leak into the input and vice versa, multi-value set-cookie survives the round-trip), plain-object → Headers conversion (string values via .set(), string-array values joined with ", ", single-element array still joined the same way (no special-case), empty array passes the !value guard truthy and gets joined to '', undefined values skipped, empty-string values skipped via the falsy guard, lowercase normalisation by Headers API), undefined input → empty Headers via the input || {} fallback. All three files mock @ever-works/agent/{database,entities} with empty class stubs to avoid pulling the agent runtime tree, and bcrypt.compare is mocked at module scope. Total apps/api/src/auth/ test suites: 12 → 15, total auth tests: 224 → 294. Closes T34 (AuthProvider implementation), T35 (AuthSyncService) from the auth-jwt-oauth Spec Kit follow-up list — every file under apps/api/src/auth/ except the abstract base + types declarations now has a unit suite. Only T36 (Playwright e2e audit) remains.

2026-05-08 — apps/api auth controllers + guard unit suites (T30–T36 from auth-jwt-oauth Spec Kit feature)

Adds 85 unit tests across four previously-zero-coverage apps/api/src/auth/ files — closes the controller + guard coverage gap called out as outstanding follow-ups T30–T36 in docs/specs/features/auth-jwt-oauth/tasks.md. auth-session.guard.spec.ts (19 tests) pins the four-mode guard surface: public-route short-circuit (returns true without inspecting request.headers or calling the auth provider when handler/class is @Public()); getAllAndOverride(IS_PUBLIC_KEY, [handler, class]) invocation shape so a future single-target check would break loudly; API-key path via x-api-key (accepts only ew_live_-prefixed values, rejects array headers, falls through to provider on non-string / non-prefixed values); API-key path via Authorization: Bearer ew_live_* (accepts Bearer ew_live_…, falls through on non-ew_live_ Bearer tokens, falls through on lowercase bearer because scheme matching is case-sensitive); precedence (x-api-key wins over Authorization when both are set); successful API-key path constructs the documented AuthenticatedUser envelope w/ iss:'ever-works'/aud:'ever-works'/iat=now, falsy-avatar coerced to null, truthy-avatar preserved verbatim; failure paths (UnauthorizedException('Invalid or expired API key') for null validateKey, UnauthorizedException('User account is inactive') for missing-user AND inactive-user — both reuse the same message); lazy ModuleRef.get(ApiKeyService, {strict:false}) + ModuleRef.get(UserRepository, {strict:false}) resolution that fires only on the FIRST API-key request and is cached on the guard instance for subsequent requests; AuthProvider fallback (returns true + attaches provider user with the original cookie header propagated through toHeaders(), throws UnauthorizedException on null provider user, propagates errors from authProvider.authenticate verbatim, treats missing request.headers as empty without crashing on toHeaders(undefined)). api-keys.controller.spec.ts (10 tests) pins the three thin endpoints — POST /api/auth/api-keys (forwards (userId, name, expiresAt) positional args verbatim incl. undefined expiresAt passthrough, propagates the 10-key-cap BadRequestException from the service); GET /api/auth/api-keys (forwards userId, returns the array reference verbatim incl. empty array, propagates DB errors); DELETE /api/auth/api-keys/:id (returns { message: 'API key revoked successfully' } on true, throws NotFoundException('API key not found') on false — pinned via instance equality so a future "wrap in BadRequest" refactor breaks loudly, propagates non-NotFound errors verbatim). Pins the critical positional invariant revokeKey(keyId, userId) — NOT (userId, keyId) — via a dedicated test so a future refactor cannot accidentally let any user revoke any key by swapping the args. oauth.controller.spec.ts (9 tests) pins both endpoints — GET /api/oauth/:providerId/url (forwards (providerId, undefined, state) to socialAuthService.getAuthorizationUrl w/ undefined-state passthrough, returns { url } envelope, propagates "Unknown provider" errors); GET /api/oauth/:providerId/callback (full socialAuth.authenticate(providerId, code) → authProvider.issueSession(userId) → activityLogService.log({...}) order pinned, exact user.login.<providerId> action shape, Signed in via <DisplayName> summary using getProviderDisplayName, metadata: { provider: providerId } payload, req.ip preferred over x-forwarded-for w/ correct fallback when req.ip is undefined, .catch(() => {}) swallows activity-log rejections without failing the response, propagates errors from socialAuth.authenticate AND authProvider.issueSession, activity-log emission registered AFTER issueSession resolves so it does NOT fire on session-store failure). auth.controller.spec.ts (28 tests) pins all 14 endpoints — getConfiguredProviders (always-on email/password + dynamic social provider list, empty list when none configured); register (assertCanRegister → signUpEmail → sendVerificationEmail order pinned, sendVerificationEmail rejection swallowed via try/catch + warn-log incl. non-Error→String coercion, missing req.headers tolerated by toHeaders(undefined), signUpEmail short-circuited when assertCanRegister rejects); login (full activity-log emission shape including user.login action, req.ipx-forwarded-for fallback, no log on signInEmail rejection, .catch(() => {}) swallows log rejection); logout/logoutAll (headers vs userId forwarding); getProfile (returns req.user reference verbatim); getFreshProfile (forwards userId to authService.getUserProfile); updatePassword (positional (currentPassword, newPassword, headers) to authProvider.changePassword); updateProfile ((userId, dto) to authService.updateUserProfile); sendVerification (forwards req.user.userId only — deliberately NO callback URL, unlike register); verifyEmail (verify→issueSession order, no session issue when verify rejects); forgotPassword (full DTO passthrough); resetPassword (four-step getUserByPasswordResetToken → setPassword → consumePasswordResetToken → signOutAll order pinned via shared order: string[] array, abort short-circuits the chain at every step incl. setPassword rejection BEFORE consume/signOutAll fire); validateEmailVerificationToken/validatePasswordResetToken (token passthrough). All four files mock @ever-works/agent/{database,entities,activity-log} with empty stubs OR minimal enum-only stubs to avoid pulling the agent runtime tree. Total apps/api auth test suites: 8 → 12, total auth tests: 139 → 224. Closes T30 (AuthController unit suite), T31 (OAuthController unit suite), T32 (ApiKeysController unit suite), T35 (AuthSessionGuard unit suite) from the auth-jwt-oauth Spec Kit follow-up list — only T33 (AuthProvider-impl-level coverage) and T34 (request-headers.toHeaders direct unit) remain.

2026-05-08 — packages/agent items-generator/item-submission.service (#593)

Adds 64 unit tests in packages/agent/src/items-generator/item-submission.service.spec.ts for the previously-zero-coverage ItemSubmissionService — the largest single uncovered file in the agent package and the final piece of the items-generator submodule (closes the row in the agent-package gap list, leaving zero remaining concentrated gaps inside @ever-works/agent). The 631-line service exposes three top-level methods used by apps/api/src/works/works.controller.ts (submitItem/removeItem/updateItem); each one performs an idempotent cloneOrPull → DataRepository.create → mutate → addAll → commit → push → optional createPullRequest flow w/ a try/catch wrapper that returns {status:'error', slug, item_name, message} instead of throwing. The spec mocks '../generators/data-generator/data-repository' (factory exposes DataRepository.create) so the suite never touches fs-extra/isomorphic-git. submitItem (29 tests): pins the workOwner-credential-vs-submitter-committer split (cloneOrPull + push + createPullRequest all use workOwner.id while commit uses work.resolveCommitter(submitter) — submitter never appears in git auth options); the three-way shouldCreatePR precedence (create_pull_request:true overrides pay_and_publish_now AND autoapproval; pay_and_publish_now:true → direct commit; autoapproval:true → direct commit; getConfig() rejection silently swallowed → null → PR fallback); categories[].length>0 wins over category w/ empty-array fallback to single string; slug Transform → slugifyText(name) derivation when missing or empty-string; screenshot capture happy path (URL prepended to images[] not appended) + dedup-when-already-present + skip-when-isAvailable-false + skip-when-source_url-empty + warn-and-continue on capture rejection + skip on success:false; default markdown body shape # <name>\n\n<description>\n\n[<url>](<url>); commit positional (provider, dest, 'Add <name>', work.resolveCommitter(submitter)); PR title format Add <name> - MM/dd/yyyy HH:mm; PR body **Badges:** section emitted only when badges set (with security/license/quality nested fields incl. optional details); **Brand:**/**Brand Logo:** lines emitted only when truthy; **Tags:** joined w/ , (Array.isArray check w/ empty-string fallback); auto_merged: false always pinned in PR mode; direct_commit: true w/ default-branch interpolation in direct mode; outer try/catch envelope; switch-to-main rejection on direct-commit re-thrown via "Failed to switch to main branch for direct commit"; graceful no-op when getMainBranch() returns null in direct mode. removeItem (12 tests): itemExists=false → '<slug>' not found, getItem=null → "Failed to retrieve item details", removeItem=false → Failed to remove item '<slug>'; shouldCreatePR branch creates remove-<slug>-<ts> branch + PR; direct-commit branch switches to main; commit message variants Remove <name> - <reason> vs Remove <name>; PR body **Reason:** <reason> only when set; addAll NOT add for staging; switch-to-main rejection swallowed in direct-commit mode (logger.error + null fallback); generic error envelope on cloneOrPull rejection. updateItem (23 tests): existingItem=null → '<slug>' not found; getItem rejection caught via .catch(() => null); updateItemMetadata=null → Failed to update item '<slug>'; partial-fields-only payload (featured/order/source_url passed through individually, order: null treated as "not provided" via != null guard pinning the Object.prototype.hasOwnProperty.call invariant); sourceUrlChanged semantics (clears health: { status: 'unchecked' } AND source_validation: undefined; identical source_url does NOT clear); commit message variants Update <name> source vs Update <name> metadata; PR branch update-<slug>-<ts> w/ create_pull_request:true; PR title Update source for <name> vs Update <name> based on sourceUrlChanged; PR body Update item source vs Update item metadata prefix + **Order:** n/a for null/undefined order + **Featured:** false via String(!!...) coercion; success envelope w/ source-vs-metadata-flavored message in direct-commit mode; addAll NOT add for staging; push w/ workOwner identity NOT submitter; generic error envelope on cloneOrPull rejection; switch-to-main rejection swallowed in direct-commit mode; skip switchBranch entirely when getMainBranch() returns null and PR is not requested. Total agent-package suite: 1957 → 2021 tests across 100 suites, all green.

2026-05-08 — packages/agent dto submodule (#581)

Adds 98 unit tests in packages/agent/src/dto/dto.spec.ts for the previously-zero-coverage dto submodule. Tests run class-validator.validate(plainToInstance(Dto, plain)) so both the class-transformer @Transform side AND the class-validator decorator side execute end-to-end on every input — no NestJS / DB context needed. Covers all 11 DTO files: GenerateDataDto (3 tests), UpdateSourceValidationDto (4 tests), UpdateWorkScheduleDto (10 tests including parametrized maxFailureBeforePause in/out-of-range), UpdateWorkAdvancedPromptsDto (24 tests across 7 prompt fields × 3 normalisation patterns + the 2000-char Transform-truncates boundary), CreateWorkDto + MarkdownReadmeConfigDto (17 tests including parametrized accepted/rejected slug regex, lowercase-Transform pre-pass converting 'UPPER''upper', nested-validation child errors), UpdateWorkDto (5 tests including committerEmail IsEmail), Taxonomy DTOs (Category/Collection/Tag with 50/100/500 char caps, 8 tests), Import DTOs (ImportSourceTypeEnum 4 documented values, AnalyzeRepositoryDto URL custom-message, ImportWorkDto sourceType isIn, GetUserRepositoriesDto page/perPage min, 10 tests), Website-settings DTOs (CustomMenuItemDto/CustomMenuDto/SettingsHeaderDto theme literals/SettingsHomepageDto, 8 tests), plus barrel re-exports (3 tests pinning 20 runtime classes, ImportSourceTypeEnum, and a runtime-symbol count regression guard). Pinned non-obvious contract: the @Transform decorators fire BEFORE class-validator runs, so sanitize-util truncations silently cap oversized input rather than failing MaxLength — dedicated tests pin this for sanitizeName/sanitizeDescription/sanitizePrompt so a future "switch the order" refactor breaks loudly. Total agent-package suite: 1572 → 1670 tests across 90 suites, all green. Closes the dto row in the agent-package submodule gap list — only account-transfer and items-generator remain.

2026-05-08 — packages/agent subscriptions submodule (#579)

Adds 81 unit tests across four new spec files in the previously-zero-coverage subscriptions submodule. subscription.service.spec.ts (50 tests): every SubscriptionService public method + every private branch reachable via the public surface. onModuleInit / seedPlans upserts the three documented seed rows (FREE/STANDARD/PREMIUM) with pinned maxWorks/monthlyPrice/overagePricePerRun/displayName/allowedCadences, all rows carry currency from config.billing.getDefaultCurrency() + active:true, all-in-parallel Promise.all rejects on any row failure. isEnabled() strict 'true' literal via parametrized it.each ('true' → true, 'false'/'TRUE'/'1'/''/unset all → false). getActiveSubscription userId passthrough. resolvePlanForUser 4-branch fallback (active sub plan → user.defaultPlan → resolveDefaultPlan when kill-switch-off short-circuits the DB lookup entirely; active-sub-without-plan-field falls through to user.defaultPlan; no-active-sub-no-defaultPlan falls through to resolveDefaultPlan). Private resolveDefaultPlan exercised via the kill-switch-off branch: env-configured plan code via SUBSCRIPTIONS_DEFAULT_PLAN, lowercase normalisation, unknown-code → FREE silent fallback at the normaliser, missing-DB-plan → FREE-fallback w/ warn log, both-missing → throws Default subscription plan not found. getCadenceAllowances returns the 7 cadences in the documented Monthly→Hourly public order; kill-switch off → all allowed:true, payPerUse:false, reason: undefined; in-plan-vs-out-of-plan partition with the upgrade-recommendation copy 'Upgrade to <Premium|Standard|Free> for this cadence'; null allowedCadences treated as zero-allowance. recommendationForCadence decision matrix pinned via parametrized it.each (HOURLY/EVERY_3_HOURS/EVERY_8_HOURS → Premium; EVERY_12_HOURS/DAILY/WEEKLY → Standard; MONTHLY → Free). getDefaultCadence returns last entry of plan.allowedCadences (smallest-interval = best slot); MONTHLY fallback for empty + null + undefined inputs. requiresUsageBilling 5-branch matrix (kill-switch-off → false, in-plan-cadence → false, out-of-plan-non-USAGE → true (caller must opt into usage billing), out-of-plan-USAGE → false (already opted in), null allowedCadences → fully out-of-plan). assignPlanToUser (BadRequest when subscriptions disabled, NotFound when plan missing in DB, lowercase normalisation 'PREMIUM' → premium, unknown-code → FREE silent fallback, undefined-input → FREE via value?.toLowerCase() short-circuit, success persists defaultPlanId via userRepository.update(userId, {defaultPlanId}) AND mutates user.defaultPlan/user.defaultPlanId in-place). summarizePlan {plan, allowances, enabled} envelope shape and kill-switch reflection. usage-ledger.service.spec.ts (15 tests): kill-switch + billing-mode AND-gate (returns null on either disabled OR non-USAGE billingMode, both-off too); happy-path persistence shape {userId, workId, scheduleId, triggerType, billingMode, units:1, amountCents, currency, generationHistoryId, metadata:{cadence}}; default 500-cent overage when PAY_PER_USE_PRICE_USD unset, custom '0.50' → 50 cents; scheduleId + metadata.cadence omitted on no-schedule and null-schedule; generationHistoryId optional; MANUAL vs SCHEDULED triggerType passthrough; billingProvider.recordUsageCharge called AFTER ledgerRepository.record succeeds (invocation order pinned via shared order: string[] array); recordUsageCharge rejection propagates to caller; record rejection short-circuits the billing call entirely. billing/billing.provider.spec.ts (8 tests): BillingProvider abstract (subclass-able function, subclasses must implement getDefaultCurrency(), default async no-op recordUsageCharge resolves undefined, subclasses can override the hook); ManualBillingProvider (extends BillingProvider, default 'usd' when BILLING_DEFAULT_CURRENCY unset, env passthrough for 'eur'/'jpy', inherits the no-op recordUsageCharge). subscriptions.module.spec.ts (10 tests): barrel re-exports of the 5 documented runtime symbols (SubscriptionsModule/SubscriptionService/UsageLedgerService/BillingProvider/ManualBillingProvider) plus a regression-guard against silent additions; @Module() Reflect-metadata (providers contains both services + a {provide: BillingProvider, useClass: ManualBillingProvider} binding; exports contains the abstract BillingProvider (NOT the manual impl, so consumers depend on the abstract token); imports contains DatabaseModule by name). Total agent-package suite: 1491 → 1572 tests across 89 suites, all green. Closes the subscriptions row in the agent-package submodule gap list — only dto, account-transfer, and items-generator remain.

2026-05-08 — packages/agent tasks + events submodules (#577)

Adds 47 unit tests across two previously-zero-coverage agent submodules. tasks/tasks.spec.ts (25 tests): pins the two DI tokens (WORK_GENERATION_DISPATCHER / WORK_IMPORT_DISPATCHER) — both plain Symbol(...) (NOT Symbol.for, so the registry cannot collide), distinct, with the documented descriptions, and re-importing returns the same singleton via the ESM module cache; WORK_GENERATION_MODE constant (literal values create/update, exactly two keys, unique values, lowercase, as const narrows the WorkGenerationMode union); the WorkImportErrorCode enum (every one of the 10 documented values pinned literally — INVALID_URL/REPO_NOT_FOUND/REPO_ACCESS_DENIED/UNSUPPORTED_FORMAT/PARSE_FAILED/CLONE_FAILED/CREATE_REPO_FAILED/GENERATION_FAILED/ENRICHMENT_FAILED/UNKNOWN_ERROR — plus 10-member count, uniqueness, and the key === value SCREAMING*SNAKE_CASE invariant); WorkGenerationDispatcher/WorkImportDispatcher interface contracts via runtime mock impls (forwards positional args, resolves to run id on success and null on failed/not-triggered branch, cancelWorkGeneration returns boolean); payload/result type exemplars (WorkContextResponse with optional gitToken, WorkContextUserDto omits password, WorkImportResult success-vs-error shapes carrying WorkImportErrorCode); plus a barrel re-export pin (Object.keys(tasksBarrel) exactly the four documented runtime symbols — type-only exports erase) so a future addition has to be a deliberate update. events/events.spec.ts (22 tests): pins the full event-name registry across all seven event classes (work.created, work.generation.completed, work.works_config.sync_requested, work.works_config.sync_failed, deployment.dispatched, deployment.completed, deployment.failed) — the wire-format strings activity-log + Sentry + worker subscribers grep — name uniqueness, and the dotted-namespace regex ^[a-z]a-z0-9*]*(\.[a-z0-9_]+)+$; the BaseEventabstract (function shape, undefined staticEVENT_NAMEslot, every published class isinstanceof BaseEvent); each constructor's positional arg capture (WorkCreatedEvent.work, WorkGenerationCompletedEvent.work+ class-distinctness fromWorkCreatedEvent, WorksConfigSyncRequestedEventworkId/userId/reason + every documentedWorksConfigSyncReasonliteral accepted,WorksConfigSyncFailedEvent5-arg shape + sharedWorksConfigSyncReason union with the Requested sibling, DeploymentDispatchedEvent payload-verbatim, DeploymentCompletedEvent optional url, DeploymentFailedEvent every documented terminalState literal 'ERROR'/'TIMEOUT'/'CANCELED'/'UNKNOWN' + optional error); instanceof discrimination across the three deployment classes; the deployment.* event-name prefix invariant; plus a barrel re-export pin (BaseEvent+ the seven event classes). Total agent-package suite: 1444 → 1491 tests across 85 suites, all green. Closes theeventsandtasksrows in the agent-package submodule gap list — onlydto, subscriptions, account-transfer, and items-generator remain.

2026-05-08 — packages/agent config + constants + onboarding submodules (#571)

Adds 157 unit tests across three previously-zero-coverage agent submodules. constants/messages.spec.ts (10 tests): pins the three exported message strings (GENERATION_CANCELLED='Generation cancelled', IMPORT_CANCELLED='Import cancelled', GIT_TOKEN_NOT_AVAILABLE='GitHub token not available') and the barrel re-export shape (exactly the three names exported from constants/index.ts). config/config.spec.ts (113 tests): pins every getter on the central config object (apps/api + packages/agent shared env-var surface). Top-level (getEnvironment passthrough, getAppType defaults to 'api', isCli true-only-when-'cli'); config.trigger (isEnabled strict 'true' literal, getApiUrl default 'https://api.trigger.dev', getMachine || undefined collapse for empty string, shouldUseTrigger AND-gate of isEnabled() && Boolean(getInternalSecret()) — both four corners pinned); config.database (getType default 'better-sqlite3', isSqlite substring match, all eight env-var passthroughs (URL/HOST/PORT/PATH/USERNAME/PASSWORD/NAME/CA_CERT) + their undefined branches via parametrized it.each, autoMigrate default-on with only literal 'false' flipping, loggingEnabled/sslMode/getInMemory strict-'true' gates with 'TRUE'/'1'/unset all returning false); config.github + config.githubApp (GITHUB_APP_PRIVATE_KEY \\n rewrite to real newlines, undefined-when-unset, no-replace-on-key-without-escapes); config.git, config.sentry, config.posthog (all simple env passthroughs + undefined-when-unset); config.subscriptions (full surface: isEnabled strict 'true', scheduledUpdatesEnabled default-on with 'false' flip, getDispatchIntervalMinutes default 5 + parseInt-strips-trailing-non-digits, getMaxBatch default 25, getDefaultPlanCode default 'free' + verbatim env passthrough w/o normalisation, getMaxFailureBeforePause default 3, getScheduleStuckTimeoutMinutes default 180, getPayPerUsePriceCents full arithmetic chain: default 500 (= $5 × 100), parseFloat * 100 rounded, Math.round half-up at 0.005 → 1, Math.max(0, …) clamps -3 to 0, parseFloat tolerance of trailing non-digits ('4.50abc' → 450), and the NaN preservation when env is fully non-numeric — pins that Math.max(0, NaN) === NaN, documenting current behavior so future refactors don't silently regress); config.websiteTemplate (autoUpdateEnabled default-on, getMinimalRepo undefined-no-default (this gates the Minimal seed in #459), getMinimalBetaBranch default null (NOT undefined) — pins the literal || null branch including empty-string → null, plus the five default-string getters via it.each); config.billing (getDefaultCurrency default 'usd', Stripe secret + webhook passthrough); config.branding three-level fallback chains for all three keys (APP_NAME → NEXT_PUBLIC_APP_NAME → 'Ever Works', COMPANY_OWNER → NEXT_PUBLIC_COMPANY_OWNER → 'Ever Co.', PLATFORM_WEBSITE → NEXT_PUBLIC_COMPANY_OWNER_WEBSITE → 'https://ever.works') with empty-string-falls-through pinned; plus a regression-guard for the top-level Object.keys(config) shape so a future addition has to be a deliberate change to BOTH the file and this test. Uses process.env = {} per-test reset + afterAll restore so no test leaks env state. onboarding/index.spec.ts (34 tests): pins the curated barrel surface — the three DI symbols (ONBOARDING_GIT_PROVIDER/ONBOARDING_ACCOUNT_UPSERT/ONBOARDING_WORK_CREATOR) MUST be Symbol.for(<key>) (registry-shared, not Symbol(<key>)) so NestJS DI tokens match across module boundaries — pinned via Symbol.for('OnboardingGitProvider') === ONBOARDING_GIT_PROVIDER round-trip, distinct-symbols set check, and description property pin; every documented runtime export is reachable via parameterized it.each (18 names: WorksManifestService, WorksManifestV1Schema, PRINTABLE_ASCII_PATTERN, SUBDOMAIN_PATTERN, isSafeWebhookUrl, redactBody, redactHeaders, redactString, REDACTED_BODY_FIELDS, REDACTED_HEADERS, WebhookDeliveryService, FetchWebhookHttpClient, WEBHOOK_HTTP_CLIENT, WEBHOOK_SIGNATURE_HEADER, StateMarkerService, STATE_MARKER_DEFAULT_PATH, OnboardingRequestRepository, WebhookSubscriptionRepository); shape assertions for the public values (PRINTABLE_ASCII_PATTERN/SUBDOMAIN_PATTERN are RegExp instances, REDACTED_BODY_FIELDS/REDACTED_HEADERS are non-empty string[], STATE_MARKER_DEFAULT_PATH/WEBHOOK_SIGNATURE_HEADER are non-empty strings, the six classes are constructable functions); plus a regression-guard for Object.keys(onboarding) so a future addition lands as a deliberate change to BOTH the barrel and this test. Total agent-package suite: 1206 → 1363 tests across 81 suites, all green. | Date | Area | PR | Notes | | ---------- | --------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 2026-05-08 | packages/agent notifications submodule (NotificationService) | #575 | Adds 34 unit tests in packages/agent/src/notifications/notification.service.spec.ts covering the previously-zero-coverage notifications submodule. create deduplication (4 tests): existing-not-dismissed → return existing + skip repository.create, existing-but-dismissed → proceed to create, no deduplicationKey → skip the pre-create lookup entirely, key-but-no-existing → proceed normally. Race-condition recovery (8 tests): all three documented unique-constraint codes recover via post-error refetch (Postgres 23505, MySQL ER_DUP_ENTRY, SQLite SQLITE_CONSTRAINT); rethrow paths pinned for refetch-returns-null, non-constraint Error (only one pre-create lookup, no refetch attempted), unique-constraint error WITHOUT a dedup key (refetch impossible → rethrow), null/undefined rejection (treated as non-constraint), string rejection (treated as non-constraint), object-without-code property (treated as non-constraint). Passthroughs (5 tests): getNotifications(userId, options) and getNotifications(userId) (undefined options forwarding), getUnreadCount, markAllAsRead, getPersistentNotifications, clearByDeduplicationKey. markAsRead (2 tests): BadRequestException when findByIdAndUserId returns null + markAsRead NOT called; happy-path findByIdAndUserId(notificationId, userId) then markAsRead(notificationId). dismiss (3 tests): BadRequestException cross-user 404 + dismiss NOT called; persistent-notification refusal with the verbatim error message "Persistent notifications cannot be dismissed. Please resolve the underlying issue first."; non-persistent happy path. 5 convenience methods: notifyAiCreditsDepleted (default templated message "Your <provider> credits have been exhausted. Please add more credits to continue." + isPersistent:true + dedup key ai_credits_depleted_<provider-lowercased> + explicit errorMessage override + lowercased dedup-key segment); notifyAiProviderError (non-persistent ERROR w/ "Error with <provider>: <msg>" template + actionUrl:'/settings'); notifyGenerationAccountError (Generation Failed title, metadata: {workId, workName}, dedup key generation_error_<workId>); notifySchedulePaused (WARNING type + /works/<id>/generator/schedule route); notifyGitAuthExpired (SECURITY category + isPersistent:true + /settings/oauth route + lowercased dedup key git_auth_expired_<provider>). cleanup (2 tests): three-delete shape {expired, dismissed, old} with deleteOlderThan called twice ({olderThanDays:7, isDismissed:true} then {olderThanDays:30}); strict ordering pinned (expired → 7-day-dismissed → 30-day-old). Total agent-package suite: 1410 → 1444 tests across 83 suites, all green. Closes the notifications row in the agent-package submodule gap list. | | 2026-05-08 | packages/agent community-pr submodule (CommunityPrProcessorService) | #573 | Adds 47 unit tests in packages/agent/src/community-pr/community-pr-processor.service.spec.ts covering the previously-zero-coverage community-pr submodule. processAllWorks (6 tests): empty works list → {processed:0, errors:[]}, default triggeredBy='schedule' propagation, per-work itemsAdded aggregation onto result.processed, per-work try/catch swallowing + errors[] push w/ String(error) coercion, fresh-vs-seeded state initialisation. processWork locking + short-circuit (10 tests): acquired:false → return 0 (NOT envelope), lock key community-pr:<workId>, ttlMs = 30 * 60 * 1000, onLocked debug log no-throw, no-open-PRs → 0, listPullRequests positional shape (owner, repo, {state:'open', perPage:100}, gitOptions), every PR matches processedPrs[].updatedAt → 0 + no getPullRequestFiles call, updatedAt changed → reprocess, legacy processedPrNumbers fallback when processedPrs undefined, explicit autoClose argument overrides work.communityPrAutoClose (and vice-versa when undefined), explicit state argument overrides work.communityPrState. processWork per-PR persistence (7 tests): persists currentState even when no items were added (lastProcessedAt='2026-05-08T12:00:00.000Z', lastError=null, totalItemsAdded=0), marks PR handled w/ outcome:'ignored' when AI returned no items, returns total items + calls workRepository.increment(work.id, 'itemsCount', N) on success, per-PR errors caught + lastError recorded + loop continues + outcome:'applied' ONLY for the successful PR, non-Error PR-loop rejection coerced to String() lastError, accumulates totalItemsAdded onto existing counter, missing-counter coerced to 0 (no NaN). processSinglePr change-context cap (3 tests): no-files → outcome:'ignored', NO aiFacade.askJson call, NO cloneOrPull; MAX_CHANGE_CONTEXT_LENGTH = 50_000 break-before-overflow (third file's small patch excluded); empty-patch '' fallback still emits header line --- <name> (<status>) ---, so the file-header alone keeps changeContext.trim() non-empty and the flow proceeds. processSinglePr extraction prompt (4 tests): aiFacade.askJson(prompt, schema, {temperature:0.3}, {userId, workId}) positional shape, getCategories rejection → "Existing categories: None defined yet" in prompt, pr.body=null → "PR Description: No description provided", , -joined category names. processSinglePr slug dedup (4 tests): in-memory seenSlugs skips intra-PR duplicates → only one writeItem for two same-named entries, data.itemExists resolved-true skips cross-run duplicates, all-duplicates → no gitFacade.add/commit/push/createPullRequestComment/closePullRequest + outcome:'ignored' + no increment, slugifyText returning empty (special-char-only name '!!!') skipped while next valid name passes through with slug='real-item'. processSinglePr commit/push/history/comment/close (9 tests): add('github', '/tmp/clone', '.') + commit('github', '/tmp/clone', 'Add 1 item(s) from community PR #17') + push({dir:'/tmp/clone'}, gitOptions), recordCommunityPrHistory calls generationHistoryRepository.createEntry w/ status: GenerateStatusType.GENERATED + activityType: WorkHistoryActivityType.COMMUNITY_PR_MERGED + triggeredBy:'api' forwarding + newItemsCount:2 + durationInSeconds:0, singular changelog wording '1 item added' (no extra s) when only 1 item added (regression-pinned), history-write fails → still returns 1 and comment still fires, createPullRequestComment body contains - Alpha\n- Beta and "Thank you for your contribution!" copy w/ gitOptions.workId forwarded, comment-fail swallowed (PR still recorded outcome:'applied', lastError=null), closePullRequest NOT called when autoClose=false, close-fail swallowed (PR still applied), writeItemMarkdown body contains # <name>, <description>, and [<source_url>](<source_url>) link. MAX_PROCESSED_PR_NUMBERS=500 FIFO eviction (1 test): 500 pre-seeded entries + 1 new PR → length stays 500, newest retained, oldest evicted. Constants (1 test): the documented ttlMs = 30*60*1000 = 1_800_000 lock TTL. Mocks '../generators/data-generator/data-repository' so DataRepository.create returns a controllable stub (no real fs-extra/isomorphic-git); the rest are plain jest.fn() mocks. Closes the community-pr row in the agent-package submodule gap list. Total agent-package suite: 1363 → 1410 tests across 82 suites, all green. | | 2026-05-08 | packages/agent cache module (factory + adapter + lock service + repository) | #569 | Adds 58 unit tests across packages/agent/src/cache/ (a previously zero-coverage agent submodule): cache.factory.spec.ts (8 tests covering CacheFactory.InMemory (delegates to CacheModule.register() w/ no args, fresh module reference per call w/ no shared state) and CacheFactory.TypeORM (imports TypeOrmModule.forFeature([CacheEntry]), forwards isGlobal undefined when omitted vs literal-true when set, useFactory constructs a TypeORMKeyvAdapter from dataSource.getRepository(CacheEntry) w/ ttl/namespace passthrough, default namespace 'app-cache' when omitted, inject array contains DataSource)); typeorm-keyv.adapter.spec.ts (37 tests covering constructor (default namespace 'app-cache', opts.ttl storage incl. undefined, custom-namespace override), get (undefined-on-missing, JSON parse on hit, expired-entry deletion + undefined return, null-expiresAt treated as never-expiring, 'error' event emission + undefined return on repo failure, namespace-prefixed lookup key <ns>:<key>), set (JSON-stringify + upsert w/ null expiresAt when ttl omitted, now+ttl expiresAt when ttl provided, false return + error emit on repo failure), delete (true when affected>0, false when affected===0, error emit + false on failure), clear (Like('<ns>:%')-scoped delete, error emit but no-throw on failure), has (true when count>0, false when count===0, error emit + false on failure), cleanExpired (LessThan(now) filter, affected || 0 undefined-coercion, error emit + 0 on failure), deleteUnscopedEntriesLike (Like('%<term>%') cross-namespace match, error emit + resolve on failure), deleteMany (true-iff-all-true, false-on-any-false), disconnect (resolves undefined), wrap (cache-hit short-circuits fn, cache-miss invokes fn + stores result, number-ttl positional arg, options-object ttl, constructor-level ttl fallback, sync-fn return-value support)); repository.spec.ts (2 tests pinning CacheEntryRepository exposes a TypeORMKeyvAdapter with default 'app-cache' namespace + no-shared-state across instances); distributed-task-lock.service.spec.ts (11 tests covering runExclusive happy path (acquire → run fn → release), stale-lock cleanup (DELETE … WHERE expiresAt < :now OR createdAt < :staleBefore) ordered BEFORE INSERT (invocationCallOrder pinned), undefined-fn-return passthrough, ttlMs capped to maxLifetimeMs, maxLifetimeMs capped to MAX_STALE_LOCK_MS = 24h, already-locked path (insert-conflict + findOne finds existing → {acquired:false} + onLocked() called + fn NOT invoked), real-DB-error path (insert-rejects + findOne returns null → re-throw), onLocked optional + no-crash when omitted, fn-rejection still releases lock w/ ≥2 delete query-builders fired, release filters by BOTH key AND value=:token (so we never release someone else's lock), heartbeat schedules setInterval w/ default max(30s, ttl/3) and explicit refreshIntervalMs override, refresh path issues UPDATE … SET expiresAt = :next WHERE key = :key AND value = :token after the configured interval — jest.useFakeTimers + advanceTimersByTimeAsync pin the heartbeat fire). Mocks @nestjs/cache-manager (CacheModule.register + registerAsync capture), @nestjs/typeorm (TypeOrmModule.forFeature capture + InjectRepository no-op), and the typeorm Repository directly with jest fns; the CacheEntry entity is imported as the real class and used as the entity-marker argument. Total agent-package suite: 1148 → 1206 tests across 78 suites, all green. Closes the cache row in the new "Agent-package submodule coverage" gap list. | | 2026-05-08 | packages/tasks worker task entrypoints (the four trigger.dev tasks) | #567 | Adds 64 unit tests across the four task entrypoints in packages/tasks/src/tasks/trigger/: work-generation.task.spec.ts (25 tests covering task() registration shape (id 'work-generation', maxDuration = 3600 * 5, run/onFailure/onCancel handler exposure), run() boots withWorkerContext('WorkGeneration', fn) and forwards (appContext, payload, TriggerGenerationOrchestrator) to createTaskContext, full positional {work, user, dto, historyId, historyStartedAt, signal} payload to orchestrator.run, {status:'completed', workId} envelope on happy path, schedule branch (triggerSource==='schedule' && scheduleIdmarkRunCompleted({scheduleId, historyId, status: GENERATED}) on non-CANCELLED, markRunFailed(scheduleId, 'cancelled') on CANCELLED final status, no-op when triggerSource !== 'schedule', no-op when scheduleId missing, markRunFailed(scheduleId, error.message) AND re-throw when orchestrator rejects in schedule mode, no schedule write but still re-throw outside schedule mode), onFailure() returns silently on missing payload, boots 'WorkGeneration:Failure' logger, normalizes the error and forwards {work, historyId, historyStartedAt, errorMessage} to orchestrator.handleFailure, marks schedule failed with normalized message in schedule mode, no schedule write outside schedule mode, swallows withWorkerContext failures (best-effort cleanup), onCancel() returns silently on missing payload, boots 'WorkGeneration:Cancel' logger, calls orchestrator.handleCancellation({work, historyId, historyStartedAt}), marks schedule failed with literal 'cancelled' in schedule mode, no schedule write outside schedule mode, swallows withWorkerContext failures), work-import.task.spec.ts (17 tests covering id 'work-import', maxDuration = 3600 * 2, run boots 'WorkImport' logger and forwards to TriggerImportOrchestrator, full {work, user, payload, gitToken} payload incl. gitToken: undefined passthrough when createTaskContext omits it, {status:'completed', workId} envelope, orchestrator.run errors propagate; onFailure returns silently on missing payload, boots 'WorkImport:Failure' logger, normalizes error and forwards {work, historyId, historyStartedAt, errorMessage}, swallows withWorkerContext failures; onCancel returns silently on missing payload, boots 'WorkImport:Cancel' logger, calls handleCancellation, does NOT swallow withWorkerContext errors — pinned as the deliberate asymmetry vs onFailure), work-onboarding.task.spec.ts (8 tests covering id 'work-onboarding', maxDuration = 3600 * 2, retry config (maxAttempts:3, factor:2, minTimeoutInMs:30_000, maxTimeoutInMs:300_000, randomize:true), run() returns {onboardingId, workId, status:'queued'} envelope, logger.info('work-onboarding.start', payload) start-event log, logger.warn(<handoff_pending message>, {workId}) deferred-handoff log, fresh-envelope-per-call no-shared-reference), work-schedule-dispatcher.task.spec.ts (14 tests covering id 'work-schedule-dispatcher', cron: '*/<n> * * * *' built from config.subscriptions.getDispatchIntervalMinutes(), Math.max(1, …) floor at 1 min for 0 AND negative inputs, large-interval passthrough (60 → */60 * * * *), run() boots NestFactory.createApplicationContext(TriggerInternalModule), installs createTriggerLogger('ScheduleDispatcher') BEFORE resolving the dispatcher (invocation order pinned), {intervalMinutes, ...summary} envelope spread from dispatcher.dispatchDue(), always-close on success, always-close + re-throw on dispatch reject, try/finally semantics — close() error overrides body error when both fire, close() error propagates on successful body, fresh boot per call (no singleton leak)). Mocks @trigger.dev/sdk (task + schedules.task capture the registered config; logger.info/warn captured for onboarding), @nestjs/core (NestFactory.createApplicationContext for the dispatcher), @ever-works/agent/{services,entities,config,tasks}, plus the local withWorkerContext/createTaskContext/createTriggerLogger/TriggerInternalModule/orchestrator-class modules so each test exercises ONLY the entrypoint glue without booting any real Nest container. Tasks suite: 100 → 164 tests across 11 files. Closes the only outstanding follow-up from the #565 row. | | 2026-05-08 | packages/plugin uncovered helpers + ai utilities | #563 | Adds 73 unit tests across 5 previously uncovered files in @ever-works/plugin: helpers/date-helpers.ts (4 tests for getCurrentDateString — non-empty, en-US Weekday, Month YYYY shape, fixed-date pinning, 4-digit year), helpers/template.utils.ts (11 tests for substituteVariables — undefined-variables passthrough, single/multi placeholder, repeated key, missing-key leave-intact rule, empty-string-substitutes-but-undefined-leaves-intact distinction, \w+-only matcher rejects dotted/dashed/spaced keys, underscore + digit identifiers, empty template), pipeline/pipeline-result.utils.ts (13 tests for the four builders — createEmptyPipelineOutputs fresh-reference + no-domainAnalysis/extra defaults, buildSuccessPipelineResult outputs + duration + state/metrics/warnings forwarding, buildErrorPipelineResult empty-outputs default + caller-supplied partial outputs preserved + string vs Error error + failedStep, buildCancelledPipelineResult literal 'Pipeline cancelled' message + outputs preservation), ai/token-usage.tracker.ts (18 tests for TokenUsageTracker.handleLLMEnd — 4 token-key shapes (promptTokens/prompt_tokens/inputTokens/input_tokens + their output mirrors), tokenUsage vs estimatedTokenUsage precedence, llmOutput vs generationInfo precedence, missing-input-output zeroes-total path, total-from-sum vs explicit-disagreeing-total honour, empty-result tolerance, overwrite-not-cumulative behaviour, promptTokens > prompt_tokens precedence), ai/reasoning.utils.ts (27 tests covering extractModelName (5 paths incl. trailing-slash and multi-level namespaces), the gpt-5.x vs plain-gpt-5 effort=none/minimal split via the lookahead, the gpt-5-mini/gpt-5-nano exclusions, the o[134] series w/ o2 rejection, gemini-2/3 google+openrouter mapping, claude-sonnet/opus 4–9 + claude-3-5+ openrouter-only mapping, claude-3-1 rejection, deepseek-r openrouter effort=low, gpt-oss groq effort=low+format=hidden, qwen3 groq effort=none-no-format, mismatched-provider returns undefined, unknown provider type returns undefined, all four per-provider convenience getters parity-check vs getReasoningConfig). Plugin test count: 94 → 167. pnpm --filter @ever-works/plugin type-check clean. | | 2026-05-08 | apps/cli unit tests scaffold (vitest + utils + credentials + http) | #555 | Vitest scaffolded in apps/cli/; 72 unit tests added across utils/{jwt.utils,error,wait,constants} (31), commands/auth/credentials.service (29), and services/http-client (12). pnpm test / pnpm test:watch scripts wired; legacy test:cli smoke-test (cli --help against the built bundle) preserved verbatim because CI workflows still call it. Mocks fs-extra for the credentials suite, axios (create() factory + interceptors.{request,response}.use capture) for the HTTP client, and ../../commands/auth.getCredentials to keep the request-interceptor suite hermetic. JWT suite pins base64url decode with -/_ translation + bad-JSON console.error path, isJWTExpired no-exp-claim → false (treat as never-expires), getJWTUserInfo AuthUser-shape projection. CredentialsService.get() five-step validation chain (file-missing / non-object / missing-or-non-string-token / not-3-dot-parts / expired) with file-removal at every rejection branch + readJson-throw + remove-also-throws double-failure path; update() no-op when get() returns null; createWithExpiry email-override + ISO expiresAt; getTokenExpiryInfo largest-unit-only projection (days OR hours OR minutes; never two simultaneously) + expiresAt fallback when JWT lacks exp; requireAuth process.exit(1) on missing creds. HttpClient covers /api-suffix normalisation (append / preserve / no-double-slash), 30-second timeout + JSON Content-Type defaults, all five HTTP verbs proxied with positional args, request interceptor injects Authorization: Bearer <token> from credentials AND rewrites baseURL when stored credentials.apiUrl differs from constructor arg, request-error passthrough, response-error 401 → process.exit(1) + still re-rejects, non-401 passthrough, getHttpClient() singleton. Closes the CLI low-priority gap. | | 2026-05-08 | api mail Resend to=undefined bug fix (T22) | #553 | Closes T22 / FR-9 / OQ-1 from the mail-providers Spec Kit feature follow-up list. Fixes the unguarded to: this.getDestination(data.to) call site at apps/api/src/mail/providers/mailer.service.ts:48 (Resend branch). Now short-circuited to to: data.to ? this.getDestination(data.to) : [], mirroring the log line's data.to ? this.getDestination(data.to).join(', ') : 'unknown' gate. The Resend branch now tolerates a missing to field (forwards to: [] to resend.emails.send) instead of throwing TypeError: Cannot use 'in' operator to search for 'address' in undefined. Existing mailer.service.spec.ts assertion flipped from "rejects with 'address' in TypeError" to "forwards to: [] to resend.emails.send" — the "to=unknown" log line still fires regardless of provider. Aligns the Resend branch's tolerance of a missing to field with the SMTP and faker branches. | | 2026-05-08 | api mail.service listener-side coverage (T21) | #552 | Adds 28 unit tests in apps/api/src/mail/mail.service.spec.ts covering all seven @OnEvent handlers (sendSignupConfirmation/sendForgotPassword/sendPasswordChanged/sendWelcomeEmail/sendNewDeviceAlert/sendAccountDeletionConfirmation/sendMemberInvitation), the getBrandingContext merge across all four branding fields (appName/companyOwner/platformWebsite/currentYear) with APP_NAME/NEXT_PUBLIC_APP_NAME fallback chain pinned, default expiresIn = '1 hour' (forgot-password) and '24 hours' (account-deletion), default dashboardUrl = ${webAppUrl}/works/new with WEB_URL env override AND http://localhost:3000 last-resort fallback, formatDateTime Intl.DateTimeFormat shape (year + long month assertions, TZ-agnostic), formatRoleName capitalise-first/lowercase-rest including mid-word casing, single-letter input, and empty-string no-crash, and the per-handler try/catch + logger.error('Failed to send …', err?.stack ?? err) swallowing policy (sendMail rejection MUST NOT propagate back to the event bus). Member-invitation log routing pinned against the INVITEE's email (NOT the inviter's) so a future "improvement" cannot silently lose the routing signal that ops engineers grep for. Closes T21 outstanding follow-up from the mail-providers Spec Kit feature; the listener-side coverage gap called out in spec.md §9 / Constitution Gate VI is now closed. | | 2026-05-08 | docs/api: activity-log + template-catalog + account | #542 | Authored three new docs/api/*.md pages covering the previously-undocumented apps/api/src/{activity-log, template-catalog, account} modules. docs/api/activity-log.md (sidebar 24): all six controller endpoints (GET /api/activity-log w/ 8 query params + Math.min(limit, 100) cap, /running-count, /summary, /export CSV w/ Content-Disposition: attachment; filename=activity-log.csv + 6-column header + 10 000-row cap, /:id w/ details.liveLogs enrichment for in-progress generation rows), the reconcileActivities(userId) debounce pattern (in-flight Promise map + 5-second TTL), the nine @OnEvent listeners w/ each event's actionType/action/status mapping (WorkCreated/WorkGenerationCompleted/WorksConfigSyncFailed/UserCreated/UserConfirmed/UserPasswordChanged/MemberInvited/DeploymentDispatched/DeploymentCompleted/DeploymentFailed), the findLatestByUserWorkActionStatus in-place update so a single user×work×run never produces two generation rows, the full 33-value ActivityActionType enum grouped by domain (Generation, Deployment, Work lifecycle, Items, Plugins, Templates, Members, Schedule, Import/Export, Settings, Auth/Account, Chat/AI, Community), the optional JitsuService analytics dispatcher (env-gated by JITSU_HOST+JITSU_WRITE_KEY, plain-object metadata gate, dispatcher MUST NOT throw), the controller-side reconcile pass logic (skip GENERATING, terminal-status mapping for CANCELLED/ERROR/everything-else), and security model (findByIdAndUserId cross-user 404, userId cascades, workId ON DELETE SET NULL, no-secrets rule for metadata/details/summary). docs/api/template-catalog.md (sidebar 25): all seven controller endpoints (GET /api/templates, POST /api/templates/custom w/ duplicate-Conflict + invalid-URL-BadRequest + 4 default fields, PUT /api/templates/custom/:templateId w/ undefined-preserve / empty-string-clear-or-preserve rules + branch-change syncBranches rewrite, POST /api/templates/custom/:templateId/archive w/ singular-vs-plural assigned-works AND inheriting-default-works refusal copy, PUT /api/templates/default, POST /api/templates/fork w/ four error classes + created: false re-adoption + seven metadata.forkedFromX audit fields + auto-set-as-default + activity-log only on created: true, POST /api/templates/refresh w/ 1-hour TTL via WEBSITE_DISCOVERY_SYNC_TTL_MS + 50×100-page safety cap), the Classic always-seeded vs Minimal env-gated (WEBSITE_TEMPLATE_MINIMAL_REPO) built-ins, idempotent findBuiltInByRepositoryCoordinates reseed, three-value originType projection (standard/forked/custom_url), getDefaultTemplateIdForUser four-level fallback chain, fire-and-forget .catch(() => {}) activity-log emission on the FIVE mutating endpoints. docs/api/account.md (sidebar 26): all eight controller endpoints (GET /api/account/export w/ ?includeSecrets=true | false+ masking viaMASKED:abc***1234placeholder for length>8,POST /api/account/import/previeww/ fullImportPreviewenvelope incl.conflicts/missingPlugins/hasMaskedSecrets, POST /api/account/import/applyw/ three-strategyConflictResolutiontable (skip/overwrite/rename) + missing-resolution-defaults-to-skip,GET /api/account/sync/statusw/SyncStatusshape,POST /api/account/sync/configure two-mode body (repoFullNamevscreateNew: true), POST /api/account/sync/pushw/includeSecretsmasking,POST /api/account/sync/pullreturning preview,POST /api/account/sync/pull/applyw/ resolutions,DELETE /api/account/syncrepo-untouched contract), the versionedAccountExportPayload (version: 1w/data.profile/data.works/data.userPlugins), the full ExportedWorkschema reference (members/customDomains/workPlugins/advancedPrompts/schedule/siteConfig/markdownTemplate/items/categories/tags/collections/comparisons), per-pluginsettingsvssecretSettingsseparation, and thecontainsMaskedSecretsimport-time warning. Updateddocs/api/index.md"Endpoint Groups" table to add Activity Log, Templates, and Account rows; updatedapps/docs/sidebarsPlatform.ts"API Reference" sidebar to include the three new entries. Closes the only remainingPending — Medium Priority"Docs gaps to audit" item —docs/api/*is now cross-checked against everyapps/api/src/**/controllers/\*.controller.ts. | | 2026-05-08 | spec kit feature: community-pr-processing (refresh) | #538 | Refreshed docs/specs/features/community-pr-processing/{spec,plan,tasks}.md to match the current implementation. Original spec (2026-05-01) had material errors: wrong triggeredBy enum values, fictitious "invalid items trigger PR comment" path (extraction failures are silent), wrong duplicate-handling description (duplicates are silently skipped at slug-level, NOT closed-with-comment), wrong endpoint path (/api/works/:id/process-community-prs not /api/works/:id/community-pr/process), missing processedPrs array with per-PR updatedAt/outcome (lets re-runs reprocess updated PRs), missing 3 constants (MAX_PROCESSED_PR_NUMBERS=500, MAX_CHANGE_CONTEXT_LENGTH=50_000, COMMUNITY_PR_LOCK_TTL_MS=30m), missing FIFO eviction at 500 entries, missing recordCommunityPrHistory writes to work_generation_history w/ WorkHistoryActivityType.COMMUNITY_PR_MERGED, wrong state-persistence cadence (per-WORK after inner loop, not per-PR), missing itemsCount increment, missing extractedItemSchema zod shape (name/description/source_url/category/tags), missing AI-call shape (aiFacade.askJson(prompt, schema, {temperature:0.3}, {userId, workId})), missing controller owner-gate via workQueryService.getWork + BadRequestException on !communityPrEnabled + cache invalidation + fire-and-forget activityLogService.log({actionType: COMMUNITY_PR_MERGED, action: 'community_pr.processed', summary: 'Processed community PRs', details: {itemsAdded}}) emission, missing scheduler outer runExclusive('works:community-pr-scheduler', fn, {ttlMs: 60*60*1000}) lock + try/catch + start/completion log lines. Spec now pins all 15 functional requirements; full edge-case enumeration including: AI returning items: []outcome:'ignored' no comment no items; diff context exceeds 50000 char cap → break loop at last fitting entry; AI throws zod failure → try/catch records lastError, loop continues; comment/auto-close/history-write failures all caught with warn-log without failing run; processedPrNumbers 500-cap with FIFO eviction (legacy array kept for backward-compat with rows missing processedPrs); processWork returns 0 when locked-out (NOT {acquired:false}). Plan documents the two trigger paths (controller manual + hourly scheduler), the dual-lock pattern (outer scheduler + per-work), the no-Trigger.dev decision (in-process Nest scheduler), the controller-emits-activity-log-but-service-does-not convention, the slug-dedup two-layer pattern (within-run Set + cross-run data.itemExists), and a 9-row risk table including the per-work lock TTL renewal gap. Tasks file lists 13 shipped tasks across 6 phases plus 13 outstanding follow-ups (T14-T26): OQ-1..OQ-6, Postgres-container integration test, e2e on manual endpoint, @Throttle guard, Trigger.dev migration, UI surfacing of state, review queue UI, monitoring alarm. Closes the community-pr-processing audit entry from COVERAGE-TRACKER.md "Pending — Medium Priority". | | 2026-05-08 | spec kit feature: integrations-github-app | #537 | Authored docs/specs/features/integrations-github-app/{spec,plan,tasks}.md covering the GitHub App integration. Spec pins all 30 functional requirements: non-@Global() GitHubAppModule (imports DatabaseModule/HttpModule/AuthModule/WorkModule/ImportModule; exports the three services so agent-onboarding can re-use them); five required env vars + three optionals (slug default 'ever-works', setup/callback URL defaults derived from webAppUrl, \\n rewrite of the PEM private key); GitHubAppService (URL builder w/ blank-string client_id fallback, OAuth code exchange w/ Unauthorized on data.error and BadRequest when no token, GitHub user resolution via shared resolveGitHubAccountEmail, getInstallation w/ app JWT, createInstallationAccessToken proxying to requestGitHubAppInstallationAccessToken, paged listInstallationRepositories w/ per_page=100 break-on-short-page or total_count met, verifyWebhookSignature short-circuit-false on missing secret, hard Error from getCredentials when GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY missing); GitHubAppOnboardingService (HMAC-SHA-256 + base64url state w/ 10-minute TTL + timingSafeEqual after length-guard; four-step find-or-create chain (link → auth-account → email-with-verified-gate → create) with bcrypt.hash(randomUUID()) placeholder password + @users.noreply.ever.works synthetic email when verified address missing + registrationProvider:'github'; username uniqueness via -2/-3/... suffix loop with 'github-user' empty-base fallback; distinct auth_accounts (provider='github', tokenType:'Bearer', metadata:{nodeId, providerUserId, login}) and github_app_user_links (separate token store) upserts; re-getInstallation + re-upsertFromGithub + claimOwnershipIfUnassigned w/ BadRequest when claim returns null); GitHubAppSyncService (list-installations fan-out via Promise.all; syncInstallation short-circuit on missing/deletedAt/suspendedAt/createdByUserId !== userId; replaceForInstallation w/ selected:true default + owner-fallback to accountLogin + fullName fallback to '<owner>/<repo>'; onboardInstallationRepository w/ data_repo-only gate + WorkImportService.onboardLinkedRepository handoff w/ mode:'github_app_installation' auth payload carrying installation+repo IDs; handleWebhook for installation (deletedmarkDeleted; suspendsuspendedAt = new Date(); unsuspendnull; created/new_permissions_accepted/unsuspend → re-sync; created ALSO captures createdByGithubUserId from payload.sender?.id) and installation_repositories (re-sync w/ no userId filter), silent return on unsupported events / missing installation id); GitHubAppController (@Public setup/callback, query DTOs w/ class-validator (installation_id required + setup_action ∈ {install, request}; code+state required), callback issues session via AuthProvider.issueSession and returns {...auth, installationId, redirectTo} envelope, management endpoints inherit global AuthSessionGuard, null from sync → 401, null from onboard → 404, {status:'error', message} from onboard → 400); GitHubAppWebhookController (@Public POST /api/github-app/webhooks w/ 400-on-missing-event-header, 400-on-missing-rawBody, 401-on-bad-signature, returns {ok:true}); race-safety guarantee from claimOwnershipIfUnassigned's WHERE createdByUserId IS NULL UPDATE; webhook signature verification via crypto.timingSafeEqual-backed verifyGitHubWebhookSignature. Plan documents the non-Global module choice, the agent-package JWT helpers (createGitHubAppJwt, requestGitHubAppInstallationAccessToken), the config.auth.secret() reuse for HMAC state, the global raw-body parser dependency for webhook signature verification, the claimOwnershipIfUnassigned race-safety pattern, and a 9-row risk table. Tasks file lists 14 shipped tasks across 7 phases (covering integrations/github-app/*.ts + the three agent-package entities/repositories) plus 16 outstanding follow-ups: T15 (OQ-1) promote getCredentials Error to ServiceUnavailableException, T16 (OQ-2) add GitHubAppSyncGuard mirroring CrmSyncGuard, T17 (OQ-3) friendly setup-error UI for unverified-email rejection, T18 (OQ-4) derive synthetic noreply suffix from webAppUrl(), T19 (OQ-5) decide second-user-installs-same-app semantics, T20 (OQ-6) log unsupported webhook event names at debug, T21 (OQ-7) decide on activity-log emission for installation lifecycle events, T22 (OQ-8) move "data_repo only" message to constant/i18n, T23 (OQ-9) preserve prior selected:false rows during replaceForInstallation, T24 Postgres-container integration test, T25 e2e against /api/github-app/webhooks w/ crafted signatures, T26 github-app.module.spec.ts, T27 GitHub Enterprise Server support, T28 bulk repo-onboarding via Trigger.dev, T29 allow non-data_repo types from the GitHub-App surface, T30 OpenAPI doc tightening for the public setup/callback envelopes. Closes the integrations-github-app row from COVERAGE-TRACKER.md "Pending — Medium Priority". | | 2026-05-08 | spec kit feature: integrations-twenty-crm | #535 | Authored docs/specs/features/integrations-twenty-crm/{spec,plan,tasks}.md covering the Twenty CRM integration. Spec pins all 25 functional requirements: global Nest TwentyCrmModule (forRoot/forRootAsync, @Global(), exports TwentyCrmService/ClientService/CrmTenantService/CrmConfigService); env-var gate (TWENTY_CRM_BASE_URL+TWENTY_CRM_API_KEY+TWENTY_CRM_WORKSPACE_ID required; optional TWENTY_CRM_TIMEOUT_MS=30000, TWENTY_CRM_MAX_RETRIES=3, TWENTY_CRM_RETRY_DELAY_MS=1000); CrmConfigService.isEnabled triple-AND with falsy-empty-string coercion + validateConfig throws-with-list-of-missing-keys; CrmSyncGuard warn-and-deny on disabled + error-and-deny on validateConfig throw; @CrmSync(enabled?) decorator stamps crm_sync metadata key; TwentyCrmService.makeRequest URL composition (<apiUrl>/rest<endpoint> vs <apiUrl>/rest/metadata<endpoint> when schema=true, Authorization: Bearer <apiKey>, X-Workspace-Id: <workspaceId | | 'default'>, Content-Type: application/json, configured timeout, debug log per request, HttpException({message, details}, status)wrap onerror.response.dataelseHttpException('Failed to communicate with Twenty CRM', 503)); ClientService16 thin-wrapper methods (POST/GET/PUT/DELETE for/companies, /contacts, /deals, /products+ 4 list helpers — note PUT for updates per OQ-3);CompaniesController4 endpoints under/api/twenty-crm/companiesw/ class-level@UseGuards(AuthSessionGuard); PeopleController4 endpoints w/ explicit field-projection on POST (firstName/lastName/email/phone/companyId/position/avatarUrl) — flagged as dead code per OQ-1+OQ-2 (missing@Controllerdecorator AND not registered in module);CrmTenantService.resolveTenantContext three-level fallback (work\_<workId>globalTenantId'global_everworks') + /tenants/<tenantId>endpoint prefix + validateTenantContext + getTenantConfig;RetryUtils.withRetry exponential back-off (delayMs * backoffMultiplier^(attempt-1)) w/ no-sleep on maxAttempts=1+ last-error re-throw;RetryUtils.isRetryableError(ECONNRESET/ETIMEDOUT/ENOTFOUND/5xx/429);RetryUtils.calculateRetryDelay(10% jitter, clamped at maxDelayMs=30000);MappingUtils.mapClientToContactfirst-token-firstName + rest-lastName (single-token → firstName-only, lastName='');mapCompanyToOrganization URL-parse-with-https://-prefix-fallback-to-undefined; mapItemToProduct/mapItemToDealUSD-default currency + NEW-stage 50%-probability deals; fourvalidate*Datahelpers returning string[] (empty when valid). Plan documents the no-Trigger.dev decision (request-scoped CRUD), the no-activity-log decision (per-row CRUD too volume-heavy), the partial-Plugin-First gate (integration is inapps/api/notpackages/plugins/twenty-crm— flagged for future migration), and a 6-row risk table. Tasks file lists 17 shipped tasks across 8 phases plus 12 outstanding follow-ups (T18 OQ-1 explicit guard on PeopleController, T19 OQ-2 wire-up-or-delete PeopleController, T20 OQ-3 PATCH-vs-PUT alignment, T21 OQ-4 unify body-validation policy, T22 OQ-5 wire-or-remove@CrmSync, T23 OQ-6 tighten forRoot typing, T24 OQ-7 static-class-vs-pure-function decision, T25 OQ-8 default-retry on makeRequest, T26 OQ-9 actually use tenant prefixes at the wire level, T27 e2e w/ nock-mocked Twenty, T28 plugin-migration to packages/plugins/twenty-crm, T29 audit createCompanybody-id rejection). Closes theintegrations-twenty-crmhalf of the gap fromCOVERAGE-TRACKER.md "Pending — Medium Priority". | | 2026-05-08 | spec kit feature: ai-conversation | #533 | Authored docs/specs/features/ai-conversation/{spec,plan,tasks}.md covering the chat-style conversation surface AND the OpenAI-compatible chat-completions endpoint. Spec pins all 23 functional requirements: per-user Conversation ((userId, updatedAt) composite + userId standalone index, ON DELETE CASCADE from users) and ConversationMessage ((conversationId, createdAt) composite + conversationId standalone, ON DELETE CASCADE from conversations); 7 controller endpoints (GET/POST /api/conversations, GET/PATCH/DELETE /api/conversations/:id, POST /api/conversations/:id/messages, DELETE /api/conversations) all behind global AuthSessionGuard w/ auth.userId as the only identity source and 404-on-cross-user via the repository's composite-key filter; first-message title derivation (/\s+/g, ' ' collapse + trim + 60-char cap with ... suffix, only when title is unset); fire-and-forget titleService.maybeGenerateTitle().catch(()=>{}) from the message-append controller; AI title gating (messageCount >= 4 && !metadata.aiTitle), summary windowing (last 4 user/assistant only, 200-char per message), temperature: 0.3, maxTokens: 30, system prompt verbatim, 100-char truncation cap; extractMessageText parts-fallback for empty content; appendMessages sequential save w/ createdAt = new Date(baseTime + i) for guaranteed ordering across drivers. OpenAI-compat (POST /api/v1/chat/completions): permissive ValidationPipe({whitelist:true, transform:true}) (NO forbidNonWhitelisted — AI SDK clients append stream_options/logprobs/parallel_tool_calls); x-provider-override + x-work-id headers forwarded to FacadeOptions; streaming branch sets the four SSE headers (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no) and ends with data: [DONE]\n\n; non-streaming branch sets Content-Type: application/json. mapToInternalOptions translates model === 'auto'undefined (facade resolves from plugin settings); three-shape message mapping (assistant-with-tool-calls / tool-result / default); content === null''. mapToOpenAiResponse: created: floor(ms/1000), non-string contentnull, usage only when present. mapToOpenAiStreamChunk: role:'assistant' normalisation when upstream delta carries one; omit content when undefined; tool-call deltas emit id/type/function.name ONLY on first chunk per tool call (continuation chunks carry only index + function.arguments — required by @ai-sdk/openai-compatible's id == null continuation parser). Pre-headers streaming error → 502 application/json {error:{message, type:'provider_error', code:'ai_provider_error'}}; post-headers error → res.destroy(error). sanitizeErrorMessage: regex redact \b(sk- | key- | token- | Bearer\s+)[A-Za-z0-9_-]{10,}\b[redacted], 300-char cap with ..., non-Errorfallback"Something went wrong. Please try again.". resolveWorkContext: passthrough on explicit workId, else findByUser(userId)[0].id, else proceed with no workId. Plan documents the four indexes, the no-Trigger.dev decision (sub-second + must not couple to message-append latency), the no-activity-log decision (user-private high-volume), the logger.error('Streaming completion error', error)+logger.debug('AI title generation failed', err)observability shape, and a 12-cell constitution-reconciliation table. Tasks file lists 19 shipped tasks across 8 phases plus 10 outstanding follow-ups (T30 OQ-1 optional persistence onchat/completions, T31 OQ-2 zero-width strip in title regex, T32 OQ-3 deterministic work resolution, T33 OQ-4 title generation provider from conversation.providerId, T34 OQ-5 batch appendMessages, T35 OQ-6 hard-deleted-user race guard, T36 OQ-7 typed AppendMessagesDto, T37 e2e against /api/v1/chat/completionsw/ mocked provider, T38 Postgres-container integration test for cascade + indexes, T39chat/completionsdoc underdocs/api/). Closes the ai-conversationgap fromCOVERAGE-TRACKER.md "Pending — Medium Priority". | | 2026-05-08 | spec kit feature: mail-providers | #528 | Authored docs/specs/features/mail-providers/{spec,plan,tasks}.md covering the transactional-email surface. Spec pins all 15 functional requirements (seven @OnEvent listeners — signup/welcome/password-changed/forgot-password/new-device-login/account-deletion/member-invitation — wired to seven Handlebars templates under apps/api/src/templates/; env-driven MAILER_PROVIDER switch with smtp/resend/Faker default-on-anything-else; Resend-without-RESEND_APIKEY graceful fallback to Faker with warn log; getDestination normalisation for string \| Address \| (string \| Address)[]; readHtmlTemplate resolution chain (template via Handlebars.compile reading ${cwd}/src/templates/<slug>.hbswithutf8data.htmlBuffer/string →data.textBuffer/string →''); branding context merge {appName, companyOwner, platformWebsite, currentYear}; formatDateTimeviaIntl.DateTimeFormat('en-US', {year:numeric, month:long, day:numeric, hour:'2-digit', minute:'2-digit', timeZoneName:'short'}); formatRoleNamecapitalise-first/lowercase-rest; per-handlertry/catch + logger.error('Failed to send …', err?.stack ?? err)policy; defaultdashboardUrl = ${webAppUrl}/works/new; default expiresIn = '1 hour'forgot-password /'24 hours'account-deletion;MailerModuleHandlebars adapterinlineCssEnabled:true+strict:true; SMTP tls.rejectUnauthorized:falseflagged as an audit follow-up;Mailer service initialized with provider: <provider>construction log; Resendid=<resendId \| 'unknown'> log line; faker default branch debug log). Plan documents the no-storage stateless design, the two log-line per-call shape (Sending → Email sent), and the deliberate decision to keep delivery synchronous-from-the-listener (no internal retry queue — bursty volume lives in upstream provider's queue). Tasks file lists 24 tasks across 8 phases; outstanding follow-ups are T21 (mail.service.spec.tslistener-side unit suite — covering all seven handlers, branding-merge, defaultdashboardUrl/expiresIn, formatters, error-swallowing policy) and T22 (fix the Resend-to-undefined crash documented in OQ-1 / FR-9). Closes the mail-providersgap fromCOVERAGE-TRACKER.md"Pending — Medium Priority". | | 2026-05-08 | spec kit feature: activity-log | #527 | Authoreddocs/specs/features/activity-log/{spec,plan,tasks}.mdcovering the per-user audit trail. Spec pins all 18 functional requirements (per-useruserId+actionType+action+status+summary, optional workIdw/ON DELETE SET NULL, optional details/metadata jsonb that MUST NOT contain secrets), all six controller endpoints (/api/activity-loglist w/Math.min(limit, 100)cap +actionType/workId/status/dateFrom/dateTo/searchfilters + default limit=25/offset=0;/running-count; /summary; /exportCSV w/Content-Disposition: attachment; filename=activity-log.csv+ 6-column header + 10 000-row hard cap +"-escape; /:idcross-user 404 +details.liveLogsenrichment fromwork.generateStatus for in-progress generation rows), the lazy reconcile front-step (reconcileActivities(userId)debounced via per-userMap<string, Promise<void>>in-flight +Map<string, number>5-secondACTIVITY*RECONCILE_TTL_MSrecently-completed cache), the fire-and-forget analytics dispatcher contract (env-gatedJitsuServiceno-op whenJITSU_HOST/JITSU_WRITE_KEYmissing;track(action, properties)plain-object metadata gate; dispatcher rejection MUST NOT throw out oflog()/updateStatus()), the WorkGenerationCompletedEventin-place update viafindLatestByUserWorkActionStatusto avoid duplicate generation rows, and the listener-sidetry/catch + logger.errorpolicy that prevents audit-write failures from propagating back to the originating domain event. Plan documents the 4-index schema, the 9@OnEventhandlers inActivityLogListener, the reconcile pass logic (skip live GENERATING, terminal-status mapping for CANCELLED/ERROR/everything-else, per-row + outer try/catch), the optional ActivityLogAnalyticsDispatchertoken, and the deliberate decision to keep reconciliation lazy-per-request rather than aDistributedTaskLockService-guarded cron. Tasks file lists 33 tasks across 8 phases; outstanding follow-ups are T28 (controller-level unit suite — currently only the agent-package service spec, the listener spec (#482, 25 tests), and the Jitsu adapter spec (#482, 9 tests) cover this area) and T29 (Postgres-container integration tests for the four composite indexes + (userId, workId) ON DELETE SET NULLbehaviour + reconcile pass against a seeded orphan). | | 2026-05-08 | api plugins (controller + validation service) | #524 | 55 unit tests acrossapps/api/src/plugins/: PluginValidationService(20) coverstryValidateConnection(null on missing/unloaded/no-validation-hooks, validateConnection success + workId forwarding, BadRequestException coercion incl. object-with-modelResults / string body via NestJS auto-wrap / "Validation failed" fallback, non-BadRequest warn-and-null, 20s timeout warn-and-null viajest.useFakeTimers, git-provider branch via gitFacade.getUser({userId, providerId}), isAvailable fallback success + isAvailable=false → BadRequest coercion w/ credentials message), validateUserPluginConnectionthrowing alias (NotFoundException on missing/unloaded, BadRequestException with{message, modelResults}on success:false, git-provider Connected message, isAvailable=false BadRequest, isAvailable=true verified message, generic "settings saved" fallback).PluginsController(35) covers all 13 endpoints — listing (4: listPlugins w/ undefined-category passthrough, getPluginsForSettingsMenu, listPluginModels, getPlugin), user management (5: enablePlugin positional(pluginId, userId, settings, secretSettings, autoEnableForWorks)+ PLUGIN_ENABLED log + fire-and-forget log-rejection swallowing + no-log-on-reject; disablePlugin PLUGIN_DISABLED log + no-log-on-reject; updatePluginSettings calls tryValidateConnection WITHOUT workId at user level — pinned viamock.calls[0]length, PLUGIN_CONFIGURED log, validation:null pinned, no-log/no-validation on service-reject; setGlobalPipelineDefaultpluginId ?? nullcoercion + undefined-enforce passthrough; validatePluginConnection error propagation), work management (4: listWorkPlugins ensureCanView BEFORE service via shared order array + skip-on-reject; enableWorkPlugin ensureCanEdit BEFORE service + OBJECT-shaped{settings, activeCapability, priority}payload w/ all-undefined passthrough +work.plugin_enabledlog w/ workId + no-log on either ensureCanEdit OR service reject; disableWorkPluginwork.plugin_disabledlog; updateWorkPluginSettings calls tryValidateConnection WITH workId — 3-arg form pinned,work.plugin_configuredlog, no-log/no-validation on either reject; setActiveCapability ensureCanEdit BEFORE service, NO activity log — pinned vianot.toHaveBeenCalled(), skip on ensureCanEdit reject). Mocks @ever-works/agent/{services,plugins,activity-log,facades,entities}plus../authto keep the agent runtime tree out of jest module loading — closes the only remaining HIGH-priority API module gap. | | 2026-05-07 | api plugins-capabilities (search + screenshot) | #520 | Adds 41 unit tests acrossapps/api/src/plugins-capabilities/: search (17) — SearchControllercoverscheckAvailability(no-plugins-enabled message, enabled-but-unconfigured message, configured-provider envelope,pluginSettings.getSettings {includeSecrets:true, userId}shape, all null/undefined/empty-string variants treated as missing required setting,x-envVar/x-adminOnlyskip semantics, undefinedsettingsSchemashort-circuits required check,required-without-propertiesshort-circuits at the field level,defaultForCapabilities-first sort order, fallback to non-default when default plugin lacks required settings); search(no-provider BadRequest with{status:"error", message}envelope,searchFacade.search(query, options, context)positional forwarding incl.providerOverride, undefined-passthrough for optional dto fields, NoProviderError→ BadRequest with "Enable a search plugin" message, generic Error → original message, non-Error → "Search failed"). screenshot (24) —ScreenshotControllercoverscheckAvailability(available:true/false, workId forwarding to BOTH registry calls +pluginSettings.getSettings, active provider from getDefaultForCapabilityScopedflagged asisDefault=true, fallback to manifest.defaultForCapabilitiesthenmanifest.systemPlugin, three-tier sort: default-first → configured-second → name alphabetical-third, activeProvider:null when no providers, manifest description/icon on each ProviderOption); capture(no-provider BadRequest before facade call, full positional forwarding toscreenshotFacade.capture(options, context), cacheUrl-over-imageUrl preference, fallback to imageUrl when cacheUrl missing, imageBuffer base64 encoding, NoProviderErrorwrapped with "configured or available", non-NoProviderError errors rethrown unchanged,result.success=false→ BadRequest withresult.erroror "Failed to capture screenshot" fallback);getScreenshotUrl (same no-provider/NoProviderError/non-NoProviderError patterns, {status:"success", imageUrl}envelope on success, null/empty/undefined imageUrl → BadRequest "Failed to generate screenshot URL"). Mocks@ever-works/agent/{facades,plugins}plus../../auth, re-exports NoProviderErrorfrom the facades mock soinstanceofmatches. | | 2026-05-07 | api plugins-capabilities (auth surface) | #518 | Adds 85 unit tests acrossapps/api/src/plugins-capabilities/: device-auth (10) — DeviceAuthService(6) +DeviceAuthController(4): positional(pluginId, userId)forwarding to PluginOperationsService, error propagation, multi-call args isolation. git-provider (24) —GitProviderService(15) covers allcheckConnectionbranches (unknown provider → Unknown placeholder; no oauth + no PAT → connected:false; oauth path uses{providerId, token}shape +authMethod=oauth; PAT-only path uses {userId, providerId}shape +authMethod=personal-access-token; falsy oauth.accessTokenfalls through to PAT shape but authMethod still'oauth'; getUser-throws warns + returns connected:false) + pass-through helpers; GitProviderController(9) covers listProviders envelope, checkConnection passthrough, getOrganizations / getRepositories / getUser success-shape{success:true, ...}+ Error-instance error message + generic fallback for non-Error rejection + page/perPageparseInt(*, 10)parsing each independently undefined-able. oauth (51) —OAuthService(29) covers checkConnection (unknown / missing-token /plugin:vssocial connectionSourceresolution viaPLUGIN*PROVIDER_PREFIX/ bothgetUserand repository failures warn-and-return connected:false),getUserBadRequest when no token,getOAuthUrl(provided-state verbatim,randomBytes(16).hex32-char fallback,forceConsentdefault undefined, scopes from settings, missingclientId/clientSecretthrows),handleOAuthCallback(full upsert payload shape withplugin:prefix +expiresAt = now + expiresIn\*1000, null-email coercion when empty, fallback to providerId when not in available list, providerInfo.enabledforwarding, missing-credentials throws before exchange, exchange-error short-circuits before upsert);OAuthController(22) coversgetConnectUrl (forceConsent strict-'true'comparison — only literal'true'→ true;'false'/''/undefined/'TRUE'→ false; missingcallbackUrlcoerced to''; Error rejection wrapped in BadRequestException with original message; non-Error → generic message), handleOAuthCallback(missing/undefined code → BadRequest before service call; full param forwarding; propagates service errors unwrapped),getUsersuccess/Error/non-Error envelope,disconnectProvider204-style undefined return + error propagation. Mocks@ever-works/agent/{facades,database,plugins}plus../../authto keep the agent runtime tree out of jest module loading. | | 2026-05-07 | Search plugins zero-coverage | #471 | brave (25 tests), linkup (27), tavily (26), valyu (29) — 107 new unit tests; mock fetch / SDK; cover metadata, settings, search, extract (where applicable), validateConnection, lifecycle, healthCheck, manifest. | | 2026-05-07 | Search plugins zero-coverage (b2) | #472 | exa (30 tests), perplexity (22), serpapi (22), firecrawl (28) — 102 new unit tests; same coverage shape as batch 1. | | 2026-05-07 | Plugin coverage (b3) | #473 | jina (25 tests), comparison-generator (12), brightdata (28), scrapfly (26) — 91 new unit tests; covers remaining zero-coverage search plugins plus utility (comparison-generator) and content-extractor (scrapfly). | | 2026-05-07 | Plugin coverage (b4) | #474 | local-content-extractor (20 tests, axios mock), github (20 tests, fetch + libsodium mock for OAuth flow) — 40 new unit tests; closes the high-priority zero-coverage plugin list. | | 2026-05-07 | cli-shared first coverage | #475 | Scaffolds vitest inpackages/cli-sharedand adds 27 unit tests (slug-utils 13 + validation-utils 14) covering slugify, validateSlug, generateIncrementedSlug, validateUrl, validateEmail, validateGitUsername, validateApiKey, validateModelName. | | 2026-05-07 | cli-shared utils extended | #476 | Adds 25 more unit tests incli-shared: config-check (10) covers maskSecret edge cases (incl. <8 char short-circuit and boundary at exactly 8) plus displayConfigurationError/Warnings; generator-steps (15) covers getStepText, getStepProgress, getDynamicStepText, getDynamicStepProgress, getItemsProcessedText. | | 2026-05-07 | cli-shared prompt services | #477 | Adds 64 unit tests for BasePromptService(45) andWorkPromptService(19). Base covers display helpers and all validators (URL, email, git username, API key, model name, slug, temperature, max tokens, git name, slugifyName). Work covers generateIncrementedSlug, formatRoleLabel, formatSelectedWork, promptWorkSelection, promptSlugConflictResolution, promptGitProviderSelection, promptDeployProviderSelection, promptWorkCreation — inquirer mocked via vi.mock. | | 2026-05-07 | monitoring first coverage | #478 | Scaffolds jest inpackages/monitoringand adds 72 unit tests across PostHog/Sentry config, services, and interceptors. PostHog config (9), Sentry config (12), AnalyticsService (15), SentryService (20), SentryInterceptor (8), PostHogInterceptor (5). Sentry SDK and posthog-node are mocked at module scope; production-vs-dev sample rates and the /auth filter onbeforeSend/beforeSendTransactionare both covered. | | 2026-05-07 | contracts first coverage | #479 | Scaffolds vitest inpackages/contracts(withtypecheckmode for.spec-d.tsfixtures) and adds 57 tests: github runtime helperparseGitHubRepositoryUrl(10),isTerminalOnboardingStatus+ONBOARDING_TERMINAL_STATUSES(10),DomainTypeenum (2), and a 35-assertion type-level fixture usingexpectTypeOfthat pins the public surface (item / domain / form / github / api/onboarding) so accidental contract regressions fail at type-check. | | 2026-05-07 | tasks first coverage | #481 | Scaffolds vitest inpackages/tasksand adds 61 tests across 4 suites:LocalPluginStore(16) — in-memory CRUD/upsert/findEnabled;TriggerLogger(18) — message/context/Error/data extraction across log/error/warn/debug/verbose/fatal + setLogLevels no-op;TriggerService(19) — dispatchWorkGeneration / cancelWorkGeneration / dispatchWorkImport with@trigger.dev/sdkconfigure+runs+task mocked, supported-machine matrix;collectPluginDependencies(8) — fs-mocked manifest discovery, workspace/@ever-works skip rules, dedup + sort. Excludes spec files andvitest.config.tsfrom the tsc build. | | 2026-05-07 | api activity-log first coverage | #482 | Adds 34 unit tests forapps/api/src/activity-log/: JitsuService(9) — env-driven enable/disable, object/array/null metadata handling, optionalworkId/details; ActivityLogListener(25) — every@OnEventhandler (work-created, generation-completed with new/in-progress entry branches and history fallback, works-config sync failure, user signup, user confirmed with provider fallback, password changed, member invited, deployment dispatched/completed/failed with URL fallback and CANCELED→cancelled mapping). Mocks@ever-works/agent/{activity-log,database,events,entities}plus../eventsto avoid the agent runtime tree. | | 2026-05-07 | api account + ai-conversation | #484 | Adds 70 unit tests acrossapps/api/src/account/(14 —AccountController covering all 9 endpoints: export with includeSecrets parsing, import preview/apply with default-[]resolutions, sync status/configure/push/pull/applyPull/remove) andapps/api/src/ai-conversation/(56 —ConversationController16,ConversationTitleService15,OpenAiCompatController4,OpenAiCompatService21). Highlights: title auto-generation gating (msg count, aiTitle metadata, fallback when work lookup fails), summary windowing (last 4 user/assistant only, 200-char cap per message), OpenAI-compat DTO→internal mapping (model="auto"→undefined, tool_calls, tool_call_id, name passthrough, null content), SSE streaming + tool_call delta passthrough (id/type/name only on first chunk), and the sanitizeErrorMessage redaction path (sk-/Bearer tokens stripped, 300-char truncation, non-Error fallback). | | 2026-05-07 | api auth (api-key + auth.service) | #486 | Adds 60 unit tests acrossapps/api/src/auth/services/: ApiKeyService(15) — 10-key cap, past-expiry rejection,ew_live*prefix + sha256 hashing, unique-key generation, validateKey expiry semantics + fire-and-forgetupdateLastUsed; AuthService(45) — assertCanRegister, validateSocialUser (new-user creation w/ trusted-email confirmation emit, unverified-email gating w/ provider-link bypass, suspended-account rejection, upsertProviderAccount field mapping incl. defaults), sendVerificationEmail (24h expiry + callback-URL token-append-vs-default), verifyEmail, forgotPassword (1h expiry, callback-URL handling, generic-message safety on unknown email), getUserByPasswordResetToken, consumePasswordResetToken, getUserProfile (sensitive-field stripping, github repo-scope gating, expired-token filtering), updateUserProfile (selective updates, committer-field clearing), validate{Email,Password}Token. Mocks@ever-works/agent/{database,entities}to avoid the agent runtime tree. | | 2026-05-07 | api mail providers | #492 | Adds 16 unit tests acrossapps/api/src/mail/providers/: FakerMailerService(2) — debug log shape + undefined-recipient tolerance;MailerService(14) — SMTP path (single recipient, mixed string+Address array log format,to=unknown log when omitted, object-without-address-key falls through to toString), Resend path (no client → faker fallback w/ warn, html-string body, Buffer html / Buffer text / empty body, Handlebars template via fs.readFilew/ correct path +utf8opts, undefinedresult.data?.idid=unknown, documents the existing unguarded getDestination(undefined)bug in resend.emails.send), faker fallback forMAILER*PROVIDERunset /none, and constructor Mailer service initialized with provider:log viaLogger.prototypespy. Mocksfs/promises.readFileat module scope. | | 2026-05-07 | api notifications module | #490 | Adds 14 unit tests acrossapps/api/src/notifications/: NotificationsController(10) — getNotifications (limit cap at 100, equal-to-cap, undefined category passthrough), getUnreadCount, getPersistentNotifications, markAsRead (success + error propagation), markAllAsRead, dismiss (success + persistent-rejection error propagation);NotificationCleanupService(4) — runExclusive key/ttl/onLocked debug log, happy-path cleanup logging "expired/dismissed/old" counts, error swallowing on cleanup failure, no-op when locked. Mocks@ever-works/agent/{notifications,cache,entities}plus stubs the../authbarrel so transitive@ever-works/agent/databaseis not pulled in. | | 2026-05-07 | api auth (social-auth.service) | #488 | Adds 37 unit tests forSocialAuthServicecovering all four OAuth providers (GitHub/Google/Facebook/LinkedIn).getAuthorizationUrl(7) — default callback, override callback+state, Google offline+consent, Facebook comma scope separator, LinkedIn space separator, unknown provider, missing client id;getProviderDisplayName(5);getConfiguredProviders(4) — full env, missing client id/secret, empty;authenticate(21) — GitHub full flow (token exchange w/ogrant_type, /user+/user/emailsresolution viaresolveGitHubAccountEmail, displayName fallback chain login→email-local-part), Google (email_verified default-true vs explicit false, expires_in→expiresAt computation w/ Date.now mock), Facebook (always-false emailVerified, picture nesting, params/fields), LinkedIn (OIDC userinfo, name vs given+family fallback, locale metadata), error paths (missing access_token, missing email per provider, missing client id/secret, unknown provider), and edge cases (readNumberrejects string expires_in,readOptionalStringrejects non-string token_type/scope/empty refresh_token). HttpService mocked with rxjsof(). | | 2026-05-07 | api onboarding adapters + well-known | #494 | Adds 43 unit tests across apps/api/src/onboarding/: OnboardingAccountAdapter(18) — covers the full GitHub-app onboarding chainfindByGithubUserId → findProviderAccountByAccountId → findByEmail → users.create, the username sanitization pipeline (strip non [a-zA-Z0-9*-], 32-char truncate, agentfallback), uniqueness suffix-2…-50then UUID-suffix fallback, provider-account upsert field shape (id/username/email/Bearer/metadata.onboardingChannel), email=null when input.email omitted, github link upsert, error swallowing onupsertProviderAccountandupsertLink(logsaccount*link_failed/gh_link_failedviadescribeError non-Error fallback), success log (account_createdvsaccount_linked); OnboardingWorkAdapter(18) — covers manifest→CreateWorkDto translation: owner extraction (URL parse,.gitstrip, malformed URL →''), slug pipeline (metadata.slugoverride, slugify, lowercase +[^a-z0-9]+→-collapse, leading/trailing-strip, 63-char truncate, empty fallback towork), description fallback (Auto-generated by Ever Works zero-friction onboarding (<id-prefix>)), deployProvider default vercel, missing user → throw, missing work id → throw createWork returned no work id, rethrow + warn log on lifecycle failure, describeErrornon-Error path;WellKnownController (7) — agent card defaults plus all four env-var overrides (PUBLIC_API_URL/PUBLIC_MCP_URL/PUBLIC_DOCS_URL/PUBLIC_CONTACT_EMAIL), and a no-shared-state assertion. Mocks @ever-works/agent/{database,services,onboarding}to avoid the agent runtime tree. | | 2026-05-07 | api subscriptions + trigger | #496 | Adds 22 unit tests acrossapps/api/src/: SubscriptionsController(9) —getPlan enabled=false envelope, enabled mapping (code/displayNamecode/name + allowances), AuthService.getUser + summarizePlan error propagation; updatePlanBadRequest when subscriptions disabled (no getUser/assignPlanToUser side effects), happy-path mapping, plan envelope usesassignPlanToUserresponse (notsummarizePlan), error propagation; TriggerInternalController(13) —getWorkContextsecret + userId guards (Forbidden / BadRequest / "secret not configured"),gitToken=undefinedwhen GitFacade returns null,user.password+work.userrelation stripped, non-user relations preserved;callRemotesuperjson serialize/deserialize round-trip incl. Date, BadRequest unknown target / unknown method, Forbidden short-circuits before invocation, all 10 expected remote targets registered afteronModuleInit, no shared state across instances. Mocks @ever-works/agent/{database,entities,cache,work-operations,tasks,services,notifications,facades,plugins,config,subscriptions}plus the../authbarrel. | | 2026-05-07 | api integrations/twenty-crm | #498 | Adds 107 unit tests acrossapps/api/src/integrations/twenty-crm/covering the entire module surface:RetryUtils(16) —withRetryexponential backoff,lastErrorpropagation, default args, no-sleep onmaxAttempts=1; isRetryableErrorECONNRESET/ETIMEDOUT/ENOTFOUND, 5xx + 429 retryable, 2xx/non-429 4xx not retryable;calculateRetryDelayexponential w/ jitter cap atmaxDelayMs; MappingUtils(19) — multi-word vs single-token name split, empty-name fallback,mapCompanyToOrganization URL/startsWith('http')host extraction + unparseable→undefined,mapItemToProduct/mapItemToDealUSD default + supplied-currency override + 50%-prob NEW-stage deal,validateContactData/validateOrganizationData/validateProductData/validateDealDatahappy + missing-field paths;CrmConfigService(10) — env reads + 3 numeric defaults (timeout 30000, retries 3, delay 1000), explicit override values,isEnabledtriple-AND across apiUrl/apiKey/workspaceId (incl. empty-string falsy coercion),validateConfiglists each missing key in error message;CrmSyncGuard(3) — disabled→false+warn (no validateConfig call), enabled+valid→true, enabled+throw→false+error log;CrmSyncdecorator (4) — default-true metadata, explicit true/false, stablecrm_synckey constant;CrmTenantService(10) —work*<id>prefix, globalTenantId fallback,global_everworksultimate fallback, workId-over-globalTenantId precedence,/tenants/<id>endpoint prefix, validateTenantContext truthy/empty/undefined, getTenantConfig optional-field preservation;TwentyCrmService.makeRequest(8) —/rest<endpoint>URL composition,/rest/metadata<endpoint>whenschema=true, default X-Workspace-Id: defaultfallback, body forwarding, HttpException pass-through with upstream message + status + details, "Twenty CRM API error" fallback message, INTERNAL_SERVER_ERROR when status missing, SERVICE_UNAVAILABLE on no-response network error;ClientService(24) — table-driven coverage across all 4 entities (companies/contacts/deals/products) × 5 operations (create/get/update/delete/list) verifying exact(method, endpoint[, body])tuples + error propagation;CompaniesController(5) +PeopleController(6) — thin-controller delegation incl. PeopleController explicit field-mapping that strips extraneous body keys and forwards undefined optional fields. Mocks@src/auth/guards/auth-session.guardto avoid the@ever-works/agent/databaseruntime tree. | | 2026-05-07 | api config + events | #500 | Adds 76 unit tests acrossapps/api/src/: config/constants.spec.ts(49) coversauthConstants, AuthProviderenum, and the fullconfigobject surface (debug, webAppUrl, auth.secret throw-on-missing, branding fallback chains, mail.provider switch incl. faker/none/resend/smtp catch-all, mail.from EMAIL_FROM verbatim vs<appName> <email>fallback w/ default[email protected], SMTP host/port/user/pwd/secure/ignoreTLS w/ NaN documented, Resend apiKey + emailFrom override, OAuth providers Google/GitHub/Facebook/LinkedIn clientId/secret/callbackUrl env mapping incl. Google connectCallbackUrlalias semantics, GitHub-App appId/clientId/secret/webhookSecret + privateKey\nunescape + slug default + setupUrl/callbackUrl webApp fallback, work.staleTimeoutHours default 2 + override + NaN, features.zeroFrictionOnboarding case-insensitivefalsegate);config/throttler.config.spec.ts(8) narrowsThrottlerModuleOptionsunion to the object form, asserts the three named tiers (short/medium/long) with exact{ttl,limit}records, monotonic ttl + limit ordering, all positive numerics, no global storage/skipIf/errorMessage/ignoreUserAgents;events/index.spec.ts(19) pinsEVENT_NAMEstrings (wire-format stability) for all 7 event classes, asserts every event's positional argument capture in order, optional-arg defaulting to undefined, BaseUserEvent inheritance for user-scoped events, confirms MemberInvitedEvent does NOT extend BaseUserEvent (uses invitee/inviter instead of user). | | 2026-05-07 | api github-app controllers | #502 | Adds 22 unit tests acrossapps/api/src/integrations/github-app/: GitHubAppController(15) covers all 5 endpoints —GET /setupfield forwarding + propagated errors,GET /callbackrunscompleteUserAuthissueSessionand mergesinstallationId+redirectTointo the session payload (skippingissueSessionon auth failure),GET /installationsuserId forwarding,POST /installations/:id/syncreturns the installation when present and throwsUnauthorizedExceptionon null/undefined,POST .../repositories/:rid/onboardrunsgetUseronboardInstallationRepositoryand throwsNotFoundExceptionon falsy result /BadRequestExceptionwith the message whenresult.status === 'error'; GitHubAppWebhookController(7) coversPOST /webhooks— missing-event-header & missing-rawBody (incl. empty string)BadRequestExceptionw/ early exit (no signature verification or dispatch),UnauthorizedException("Invalid GitHub webhook signature")on verifyWebhookSignature=false, signature header may be undefined, happy path returns{ ok: true }, propagates handler errors, @Publicsmoke check. Mocks@ever-works/agent/{database,entities,import,services}plus the@src/authbarrel and the@Publicdecorator to avoid the auth+agent runtime tree. | | 2026-05-07 | api works/members + dtos | #506 | Adds 45 unit tests acrossapps/api/src/works/: members.controller.spec.ts(15) covers all 6 endpoints —listMembers {members,owner}envelope w/ getUser→user.id forwarding,inviteMemberbuildsworkUrlfromconfig.webAppUrl()and emitsMemberInvitedEventw/ positional args (no-event on rejection),getMemberpass-through,updateMemberRolefire-and-forget activity log w/MEMBER_ROLE_CHANGED (.catch(()=>{})swallow),removeMemberw/MEMBER_REMOVED, leaveWorkpass-through;dto/dto.spec.ts(30) covers class-validator behavior across all 6 DTOs —InviteMemberDto(6: assignable roles + IsEmail + IsNotEmpty + the exact "Role must be one of: viewer, editor, manager" message),UpdateMemberRoleDto(3),ValidateTokenDto(3: empty-string allowed since no IsNotEmpty),BatchDeployDto(6: ArrayMinSize=1, @ValidateNested w/ Type(()=>BatchDeployItemDto), default teamScope IsString),BatchDeployItemDto(3),GenerateWorkDetailDto(5),GenerateManualComparisonDto(4). Mocks@ever-works/agent/{services,activity-log,entities}, ../auth(barrel), and../config/constantsto keep the agent runtime tree out. | | 2026-05-07 | api works.controller (items) | #511 | 14 tests forapps/api/src/works/works.controller.ts items mutation endpoints (submitItem/removeItem/updateItemMetadata/checkItemHealth/extractItemDetails/bulkCaptureImages). Pins the activity-log shapes for ITEM_ADDED/ITEM_REMOVED/ITEM_UPDATED, the Added item: ${name | | 'New item'}summary fallback, theRe-checked item source: ${result.item_name | | dto.item_slug}fallback, the cache-invalidation pattern (submit/remove/update/checkItemHealth invalidate; extract + bulkCaptureImages do NOT — both pinned), and the no-log / no-invalidate paths on every service rejection. | | 2026-05-07 | api works.controller (domain+repo) | #513 | 23 tests forapps/api/src/works/works.controller.ts domain + repository endpoints (updateDomainType/regenerateMarkdown/updateReadme/updateWebsiteRepository/switchWebsiteTemplate/syncWorkData/getRepositoryVisibility/updateRepositoryVisibility). Pins updateDomainTypepositional(id, domainType, user, manuallySet??true) w/ NO activity log; SETTINGS_UPDATED actions (work.markdown_regenerated/work.readme_updated) and WEBSITE_SETTINGS_UPDATED action (work.website_updated) w/ fire-and-forget log rejection swallowing + no-log-on-reject; switchWebsiteTemplatesummary mapping for all fourswitchMode values (no_change→ service-providedmessageverbatim,saved_for_initialization/repository_reset/repository_recreated) + full details payload (previousWebsiteTemplateId, websiteTemplateId, switchMode, repositoryRecreated, repository) + null websiteTemplateIdclear semantics;syncWorkDataordering (sync FIRST, theninvalidateWorkCaches) pinned via shared callOrderarray + WORK_UPDATEDwork.synced_from_data_repow/{syncStatus, updatedFields, message}details + no-log/no-invalidate on reject;getRepositoryVisibility+updateRepositoryVisibility workOwnershipService.ensureAccess(id, user.id)runs BEFORE the management-service call (invocation order asserted), forwards positional(work, user[, repoType, isPrivate])w/ NO log + access-rejection short-circuits the management call. | | 2026-05-07 | api works.controller (advanced-prompts + import) | #514 | 19 tests forapps/api/src/works/works.controller.tsadvanced-prompts (GET+PUT) and import (analyze, analyze-for-linking, import, repositories) endpoints. Pins:getAdvancedPrompts/updateAdvancedPromptsforwardauth.userIddirectly w/o resolving viaauthService.getUser(asserted explicitly), PROMPTSUPDATEDwork.prompts_updatedlog w/ fire-and-forget log-rejection swallowing + no-log-on-reject;analyzeRepository/analyzeForLinkingforward(dto, user)w/ NO log + propagate errors;importWorkIMPORTwork.import_startedlog w/{sourceUrl, sourceType, gitProvider}details (undefined passthrough when omitted), log payload has NOworkId(asserted explicitly — import is global, not work-scoped);getUserRepositoriesparses page+perPage withparseInt(*, 10)(each independently undefined-able), empty-stringsearch/ownercoerced via | | undefined, typepassthrough for both'user'and'org', NO activity log. | | 2026-05-07 | api works.controller (taxonomy CRUD) | #515 | 21 tests for apps/api/src/works/works.controller.tstaxonomy CRUD (categories/tags/collections — create/update/delete = 9 endpoints). Pins ALL 9 endpoints' positional service args, cache-invalidation-AFTER-service ordering (sharedorder array w/ mockImplementation push on the createCategory canary), SETTINGSUPDATED log w/ entity-specific action (taxonomy.<entity>*<verb>) and entity-specific summary (create interpolates dto.name, update/delete uses verb-only summary), update logs include details: { <entity>Id, name }w/ undefinednamepassthrough, delete logs includedetails: { <entity>Id }, service rejection short-circuits BOTH cache invalidation AND log emission, fire-and-forget log-rejection swallowing pinned across the three endpoint shapes (create/delete/update) on three sample endpoints. | | 2026-05-07 | api works.controller (comparisons + community-pr + source-validation) | #516 | 23 tests for apps/api/src/works/works.controller.ts final remaining endpoint groups: processCommunityPrs + listComparisons / getRemainingComparisonCount / getComparisonGenerationStatus / getComparison / generateNextComparison / generateManualComparison / deleteComparison + source-validation GET+PUT. Pins: community-pr NotFound (findById null) and BadRequest (!communityPrEnabled) BEFORE the service call (no cache invalidation, no log) and COMMUNITY_PR_MERGED happy-path log w/ details: { itemsAdded } and {itemsAdded} envelope; comparisons access-check ordering (getWork BEFORE service for read endpoints, ensureCanEdit BEFORE service for mutation endpoints), getComparisonGenerationStatus does NOT call getWork (pinned explicitly), getComparison NotFoundException on both null AND undefined result.comparison, generateManualComparison BadRequestException on equal slugs (no service call, no log, but ensureCanEdit STILL ran), manual-flavored summary Generated comparison: <a> vs <b> w/ {status, slug, itemASlug, itemBSlug, message} details, deleteComparison log has slug-interpolated summary AND no details field (pinned via not.toHaveProperty); source-validation: GET runs ensureAccessgetCadenceAllowancesgetSettings ordering, NO log; PUT runs ensureCanEditgetCadenceAllowancesupdateSettings, SETTINGS_UPDATED work.source_validation_updated log w/ details {cadence, enabled} (undefined passthrough), no-log-on-reject. This closes the per-endpoint-group split for works.controller.ts — every endpoint now has unit-test coverage. | | 2026-05-07 | api works.controller (schedule) | #510 | 14 tests for apps/api/src/works/works.controller.ts schedule endpoints (getWorkSchedule/updateWorkSchedule/cancelWorkSchedule/runScheduledUpdate). Pins: updateWorkSchedule branches on runImmediately && status === ACTIVE — immediate workGenerationService.runScheduledUpdate fires ONLY in that case (not on paused, not when runImmediately=false); SCHEDULE_UPDATED log emitted regardless of immediate-run path; fire-and-forget runScheduledUpdate failure logged w/ Error.stack vs String(error) for non-Error rejects (response resolves before the rejection is logged — microtasks flushed via setImmediate). cancelWorkSchedule SCHEDULE_DELETED log + no-log-on-service-rejection. runScheduledUpdate throws BadRequestException on non-ACTIVE status with NO activity log + NO service call; happy path returns {status:'pending', slug, message} envelope with work.slug fallback to the work id when slug missing; same Error.stack vs String(error) variants for the deferred failure log. | | 2026-05-07 | api works.controller (generation) | #509 | 17 tests for apps/api/src/works/works.controller.ts generation + cancellation endpoints (generateItems/updateItemsGenerator/cancelGeneration/getGlobalGeneratorFormSchema/getGeneratorFormSchema/generateWorkDetails). Pins: generateItems log payload + awaitCompletion=false flag, log-rejection swallowed, log fires BEFORE service call (so a service rejection does NOT skip the log); updateItemsGenerator OBJECT-shaped payload ({workId, updateDto, user, awaitCompletion}, not positional — refactor to positional would be a contract break); cancelGeneration no activity log; getGeneratorFormSchema runs ensureAccess(id, user.id) BEFORE getFormSchema (invocation order asserted, no schema lookup on access denial); getGlobalGeneratorFormSchema does NOT include workId in the form context (only userId); generateWorkDetails positional (work_name, prompt, user, ai_provider) w/ undefined ai_provider passthrough. | | 2026-05-07 | api works.controller (CRUD batch) | #508 | 29 tests for apps/api/src/works/works.controller.ts core CRUD endpoints (getWorks/getWorkStats/getWebsiteTemplates/createWork/getWork/updateWork/getWorkItems/getWorkConfig/getWebsiteSettings/updateWebsiteSettings/getWorkStatus/getWorkCategoriesTags/getWorkHistory/deleteWork). Pins query-param parsing semantics (limit=0 short-circuits to undefined via parsedLimit && !isNaN, NaN→undefined, empty string→undefined), cacheManager.wrap usage with all four work-cache keys + WORK_CACHE_TTL_MS, activity-log fire-and-forget pattern (success + log-rejection swallowing + no-log-on-service-rejection), website-settings cache invalidation via cacheEntryRepository.typeormAdapter.deleteUnscopedEntriesLike, and the website-templates global-default fallback when no template advertises isDefault. Mocks @ever-works/agent/{dto,items-generator,services,comparison-generator,template-catalog,generators,community-pr,database,cache,entities,subscriptions,activity-log} plus the ../auth barrel to avoid the agent runtime tree. | | 2026-05-07 | api works/tasks schedulers | #504 | Adds 59 unit tests across apps/api/src/works/tasks/ — full schedulers surface. CommunityPrSchedulerService (6) — works:community-pr-scheduler lock + 1h ttl + onLocked debug, processed/errors log, Error.stack vs String(error) outer error, locked-branch no-op. ComparisonSchedulerService (7) — works:comparison-scheduler lock, per-work respectCadence:true forward, generated/skipped/errors counters, unknown-status not counted, per-work String(error) on non-Error reject, outer error variants. ItemSourceValidationCronService (5) — works:item-source-validation-scheduler lock, four cache prefixes (items/config/count/categories-tags) deleted in parallel via CacheEntryRepository.typeormAdapter.deleteUnscopedEntriesLike, outer error variants, locked-branch skip. WebsiteTemplateSchedulerService (10) — works:website-template-scheduler lock, config.websiteTemplate.autoUpdateEnabled() gate, no-eligible early return, per-work lastChecked update, updateCheck.error records websiteTemplateLastError, missing-work.user skip, full update path with websiteTemplateLastCommit/websiteTemplateLastUpdatedAt, fallback latestCommit when result.commitSha missing, "Unknown error during template update" fallback for non-Error throws. WorkCacheWarmupService (11) — works:cache-warmup lock 9-min ttl, totalEligible=0 early return, single-work warm sets all 4 keys with WORK_CACHE_TTL_MS, GENERATING/!user.id skip, per-work warn on Error or non-Error, cursor advance + wrap-around batch when totalEligible > BATCH_SIZE (25), invalid cursor → 0, zero-window resets cursor to 0, outer Error.stack vs String(error). WorkCleanupService (11) — works:cleanup lock 9-min ttl, staleTimeoutHours()-derived staleThreshold, GENERATING stalled → ERROR + recordGenerationFinishTime + refetch + eventEmitter.emit(WorkGenerationCompletedEvent), non-GENERATING stalled keeps status, null-refetch no-emit, findOrphanedGenerating → ERROR with Generation stalled — automatically recovered, outer Error.stack vs String(error); clearWorkCache @OnEvent(WorkGenerationCompletedEvent.EVENT_NAME) deletes by data.work.id and swallows on cache failure with logger.error. WorkScheduleDispatcherCronService (9) — works:schedule-dispatcher lock, scheduledUpdatesEnabled + !shouldUseTrigger gates, isDispatchMinute (epochMinute % interval === 0) skip when not aligned, ttlMs = max(intervalMinutes * 60_000, 60_000) clamp incl. interval=0 → 60_000, dueCount=0 + failed=0 silence, only-failed log path, outer Error.stack vs String(error). Mocks @ever-works/agent/{cache,community-pr,comparison-generator,config,database,entities,events,generators,services} plus @src/config/constants so the agent runtime tree is not pulled in. |

Pending — High Priority

These zero-coverage areas yield the biggest coverage % per PR.

Search-plugin unit tests (per plugin)

The pattern is established by groq / screenshotone / urlbox: vitest + fetch-or-SDK mock, ~10–20 tests asserting metadata, settingsSchema shape, search/extract success + error paths, lifecycle, healthCheck, manifest.

  • brave (fetch-based, search only) — 25 tests, 2026-05-07
  • linkup (fetch-based, search + content-extractor) — 27 tests, 2026-05-07
  • tavily (SDK @tavily/core, search + content-extractor) — 26 tests, 2026-05-07
  • valyu (SDK valyu-js, search + content-extractor) — 29 tests, 2026-05-07
  • exa (SDK exa-js, search + content-extractor) — 30 tests, 2026-05-07
  • perplexity (SDK @perplexity-ai/perplexity_ai, search) — 22 tests, 2026-05-07
  • serpapi (fetch-based, search) — 22 tests, 2026-05-07
  • firecrawl (SDK @mendable/firecrawl-js, search + content-extractor) — 28 tests, 2026-05-07
  • jina (fetch-based, search + content-extractor) — 25 tests, 2026-05-07
  • scrapfly (SDK scrapfly-sdk, content-extractor + screenshot) — 26 tests, 2026-05-07
  • brightdata (SDK @brightdata/sdk, search + content-extractor) — 28 tests, 2026-05-07

Other zero-coverage plugins

  • comparison-generator (utility category) — 12 tests, 2026-05-07
  • github (git-provider + OAuth) — 20 tests, 2026-05-07
  • local-content-extractor (content-extractor — default) — 20 tests, 2026-05-07

Agent-package submodule coverage

These submodules under packages/agent/src/ had ZERO *.spec.ts files on 2026-05-08 (the initial scan after the per-plugin / per-internal-package sweep). Pick the next-smallest one off the list and ship in a single PR; follow the existing agent-package Jest pattern (mock TypeORM Repository shapes + @nestjs/common Logger, no real DB). Sorted ascending by source-file count so the smallest wins are first.

  • cache — 4 source files, 5 with index. 58 tests across cache.factory.spec.ts, typeorm-keyv.adapter.spec.ts, repository.spec.ts, distributed-task-lock.service.spec.ts (#569, 2026-05-08).
  • community-pr — 3 source files. 47 tests in community-pr-processor.service.spec.ts (#573, 2026-05-08) covering processAllWorks (per-work error swallowing, non-Error coercion, processed count aggregation, fresh-vs-seeded state), processWork lock + short-circuit branches (lock-key shape community-pr:<workId>, ttlMs = 30 * 60 * 1000, onLocked debug log, no-open-PRs early return, listPullRequests positional shape (owner, repo, {state:'open', perPage:100}, gitOptions), processedPrs.updatedAt match → skip, updatedAt-changed → reprocess, legacy processedPrNumbers fallback when processedPrs is undefined, explicit autoClose + state arg overrides), per-PR persistence (lastProcessedAt set even on zero-items, processedPrs[].outcome === 'ignored' when AI returned no items, workRepository.increment(work.id, 'itemsCount', N) only when totalItemsAdded > 0, accumulated totalItemsAdded onto existing counter, missing-counter coerced to 0, error swallowing inside the inner PR loop, non-Error rejection coerced to String() lastError), processSinglePr change-context cap (no-files → ignored/0 + no clone, MAX_CHANGE_CONTEXT_LENGTH = 50_000 break-before-overflow, empty-patch fallback '' still emits the header line, header-only context still passes .trim() non-empty gate), AI prompt shape ((prompt, schema, {temperature:0.3}, {userId, workId}) positional, "Existing categories: None defined yet" on getCategories rejection, ", "-joined category names, "PR Description: No description provided" when pr.body is null), slug dedup (in-memory seenSlugs skips intra-PR duplicates, data.itemExists skips cross-run duplicates, all-duplicates → no commit/push/comment + outcome:'ignored', empty-slug after slugifyText skipped), commit/push/history/comment/close (positional add(provider, dest, '.') + commit(provider, dest, 'Add N item(s) from community PR #X') + push({dir}, gitOptions), recordCommunityPrHistory with status: GENERATED + WorkHistoryActivityType.COMMUNITY_PR_MERGED + triggeredBy forwarding + singular-vs-plural changelog wording, history-write-fail swallowed, comment body contains - <name> per item + "Thank you for your contribution!" copy, comment-fail swallowed, closePullRequest called only when autoClose=true, close-fail swallowed), MAX_PROCESSED_PR_NUMBERS = 500 FIFO eviction, and the documented 30 * 60 * 1000 lock TTL constant. Mocks '../generators/data-generator/data-repository' (factory exposing DataRepository.create) so the test does not exercise real fs-extra/isomorphic-git. Total agent-package suite: 1363 → 1410 tests across 82 suites, all green.
  • notifications — 3 source files (notification.service.ts 260 lines). 34 tests in notification.service.spec.ts (#575, 2026-05-08) covering create deduplication (existing-not-dismissed → return existing + skip create, existing-but-dismissed → proceed, no-key → skip lookup, key-but-no-existing → proceed), race-condition recovery on unique-constraint error (Postgres 23505, MySQL ER_DUP_ENTRY, SQLite SQLITE_CONSTRAINT all → re-read existing), rethrow paths (refetch returns null, non-constraint Error, no-dedup-key path, null/undefined error, string error, object-without-code), passthroughs (getNotifications w/ + w/o options, getUnreadCount, markAllAsRead, getPersistentNotifications, clearByDeduplicationKey), markAsRead cross-user 404 + happy-path repo call, dismiss cross-user 404 + persistent-refusal verbatim error message + non-persistent happy path, and the five convenience methods (notifyAiCreditsDepleted w/ default-template + override + lowercased dedup key, notifyAiProviderError non-persistent w/ Error with <provider>: <msg> template, notifyGenerationAccountError workId-stable dedup + metadata, notifySchedulePaused warning routed to /works/<id>/generator/schedule, notifyGitAuthExpired persistent error routed to /settings/oauth + lowercased dedup), plus cleanup three-step delete in order (deleteExpireddeleteOlderThan({olderThanDays:7, isDismissed:true})deleteOlderThan({olderThanDays:30})) returning {expired, dismissed, old}. Total agent-package suite: 1410 → 1444 tests across 83 suites, all green.
  • events — 7 event-class files. 22 tests in events/events.spec.ts (2026-05-08, #577) covering the full EVENT_NAME wire-format registry across all seven classes (work.created, work.generation.completed, work.works_config.sync_requested, work.works_config.sync_failed, deployment.dispatched, deployment.completed, deployment.failed), name uniqueness, dotted-namespace regex, the BaseEvent abstract (function shape, undefined static EVENT_NAME slot, every published class extends it), every constructor's positional arg capture (WorkCreatedEvent.work, WorkGenerationCompletedEvent.work + class-distinctness from WorkCreatedEvent, WorksConfigSyncRequestedEvent workId/userId/reason + every documented WorksConfigSyncReason literal accepted, WorksConfigSyncFailedEvent 5-arg shape + shared WorksConfigSyncReason union with the Requested sibling, DeploymentDispatchedEvent/DeploymentCompletedEvent (optional url)/DeploymentFailedEvent (every documented terminalState literal + optional error)), instanceof discrimination across the three deployment classes, and the deployment.* event-name prefix invariant; plus a barrel re-export pin (BaseEvent + the seven classes — type-only exports erase) so a future runtime addition has to be a deliberate update.
  • subscriptions — 5 source files (subscription.service.ts 247 lines, usage-ledger.service.ts 53 lines, plus billing/ subdir). 81 tests across four spec files (2026-05-08, #579): subscription.service.spec.ts (50 tests covering onModuleInit/seedPlans upserts (FREE/STANDARD/PREMIUM rows w/ pinned maxWorks/monthlyPrice/overagePricePerRun + currency from config.billing.getDefaultCurrency() + active:true, all-in-parallel Promise.all rejects on any row failure), isEnabled() strict 'true' literal w/ parametrized 'TRUE'/'1'/''/unset all → false, getActiveSubscription userId passthrough, resolvePlanForUser 4-branch fallback chain (active sub plan → user.defaultPlan → resolveDefaultPlan; kill-switch-off short-circuit skips DB), resolveDefaultPlan private branch (env-configured plan code w/ SUBSCRIPTIONS_DEFAULT_PLAN, lowercase normalisation, unknown-code → FREE silent fallback at normaliser, missing-DB-plan → FREE-fallback w/ warn log, both-missing → throws Default subscription plan not found), getCadenceAllowances (kill-switch off → all 7 cadences allowed:true, payPerUse:false, public order Monthly→Hourly preserved, in-plan vs out-of-plan partition w/ Premium/Standard/Free upgrade-recommendation copy 'Upgrade to <X> for this cadence', null allowedCadences → zero-allowance), recommendationForCadence matrix via parametrized it.each (HOURLY/EVERY_3_HOURS/EVERY_8_HOURS → Premium; EVERY_12_HOURS/DAILY/WEEKLY → Standard; MONTHLY → Free), getDefaultCadence (last-allowed-entry, MONTHLY fallback for empty + null + undefined), requiresUsageBilling (kill-switch-off → false, in-plan-cadence → false, out-of-plan-but-USAGE → false (already opted in), out-of-plan-non-USAGE → true, null allowedCadences treated as fully out-of-plan), assignPlanToUser (BadRequest when disabled, NotFound when plan missing, lowercase normalisation, unknown→FREE, undefined input → FREE via value?.toLowerCase() short-circuit, success persists defaultPlanId via userRepository.update(userId, {defaultPlanId}) + mutates user.defaultPlan/user.defaultPlanId in-place), summarizePlan {plan, allowances, enabled} envelope shape and kill-switch reflection); usage-ledger.service.spec.ts (15 tests covering kill-switch + billing-mode AND-gate (returns null on either disabled or non-USAGE billingMode, both-off too), happy-path persistence shape ({userId, workId, scheduleId, triggerType, billingMode, units:1, amountCents, currency, generationHistoryId, metadata:{cadence}}), default 500-cent overage when env unset, custom PAY_PER_USE_PRICE_USD='0.50' → 50 cents, scheduleId+cadence omitted on no-schedule + null-schedule, generationHistoryId optional, MANUAL vs SCHEDULED triggerType passthrough, recordUsageCharge called AFTER record succeeds w/ pinned invocation order, recordUsageCharge rejection propagates, record rejection short-circuits the billing call entirely); billing/billing.provider.spec.ts (8 tests covering BillingProvider abstract (subclass-able function, subclasses must implement getDefaultCurrency(), default async no-op recordUsageCharge, override-able), ManualBillingProvider (extends BillingProvider, default 'usd' from BILLING_DEFAULT_CURRENCY unset, env passthrough for 'eur'/'jpy', inherits the no-op recordUsageCharge)); subscriptions.module.spec.ts (10 tests pinning the barrel re-exports — 5 documented runtime symbols (SubscriptionsModule/SubscriptionService/UsageLedgerService/BillingProvider/ManualBillingProvider) — and the @Module() Reflect-metadata (providers contains both services + a {provide: BillingProvider, useClass: ManualBillingProvider} binding; exports contains the abstract BillingProvider (NOT the manual impl), imports contains DatabaseModule by name)). Spec already exists at docs/specs/features/subscriptions/; this row is now closed.
  • tasks — 5 source files (agent-internal — distinct from packages/tasks/ which is already covered). 25 tests in tasks/tasks.spec.ts (2026-05-08, #577) covering the two DI tokens (WORK_GENERATION_DISPATCHER/WORK_IMPORT_DISPATCHER) — both plain Symbol(...) (NOT Symbol.for, so the registry cannot collide), distinct, with the documented descriptions, and re-importing returns the same singleton via the ESM module cache; WORK_GENERATION_MODE constant pinned (literal values create/update, exactly two keys, unique values, lowercase, as const narrows WorkGenerationMode to the literal union); the WorkImportErrorCode enum (every one of the 10 documented values pinned literally — INVALID_URL/REPO_NOT_FOUND/REPO_ACCESS_DENIED/UNSUPPORTED_FORMAT/PARSE_FAILED/CLONE_FAILED/CREATE_REPO_FAILED/GENERATION_FAILED/ENRICHMENT_FAILED/UNKNOWN_ERROR — plus 10-member count, uniqueness, and key === value SCREAMING_SNAKE_CASE invariant); WorkGenerationDispatcher/WorkImportDispatcher interface contracts via runtime mock impls (forwards positional args, resolves to run id on success and null on failed/not-triggered branch, cancelWorkGeneration returns boolean); payload/result type exemplars (WorkContextResponse with optional gitToken, WorkContextUserDto omits password, WorkImportResult success-vs-error shapes carrying WorkImportErrorCode); plus a barrel re-export pin so a future runtime addition has to be a deliberate update.
  • dto — 11 DTO files (class-validator decorator wrappers; tests prove the validation rules + Transform pipelines actually fire). 98 tests in dto/dto.spec.ts (2026-05-08, #581) using class-validator.validate(plainToInstance(Dto, plain)) so both transformer AND validator sides execute end-to-end. Per-DTO: GenerateDataDto (3 tests: accepts non-empty slug+prompt, rejects empty/missing → isNotEmpty, rejects non-string → isString); UpdateSourceValidationDto (4 tests: enabled-false-no-cadence, enabled-true-with-DAILY, non-bool enabled, out-of-enum cadence); UpdateWorkScheduleDto (10 tests: full-payload, all-optional, parametrized in/out-of-range maxFailureBeforePause (rejects 0/-1/11/100/1.5 via min/max/isInt; accepts 1/5/10), out-of-enum cadence, non-bool fields); UpdateWorkAdvancedPromptsDto (24 tests across 7 prompt fields × 3 patterns: empty/whitespace/non-string normalises to null, valid string survives sanitize, plus the MAX_PROMPT_LENGTH=2000 Transform-truncates-before-validate behavior pinned for input >2000 → cap, =2000 boundary passes); CreateWorkDto (15 tests: valid base, slug Transform lowercase+trim, parametrized rejected slugs ('has space'/'has_underscore'/'ends-'/'-starts'/'invalid--double'/'punc!') via matches, parametrized accepted slugs, lowercase-Transform pre-pass converts 'UPPER' to 'upper' instead of rejecting, MaxLength-NOT-tripped-because-Transform-truncates-first behavior pinned, gitProvider/deployProvider/websiteTemplateId lowercased, gitProvider defaults to 'github', missing organizationisBoolean, nested readmeConfig validation produces child errors); MarkdownReadmeConfigDto (2 tests: empty config, non-bool overwrite flags); UpdateWorkDto (5 tests: empty payload, invalid committerEmailisEmail, valid email, deployProvider+websiteTemplateId lowercased, sanitize-truncates-before-validate); Taxonomy (8 tests: CreateCategoryDto valid + sanitize-truncates + negative priority rejected → min, UpdateCategoryDto empty, CreateCollectionDto smoke, UpdateCollectionDto empty, CreateTagDto 50-char tighter cap pinned, UpdateTagDto empty); Import DTOs (10 tests: ImportSourceTypeEnum 4 documented values + uniqueness, AnalyzeRepositoryDto valid URL + custom error message 'Please provide a valid repository URL', ImportEnrichmentConfigDto non-numeric → isNumber, ImportWorkDto requires sourceUrl/sourceType/name/gitProvider, deployProvider lowercased, out-of-enum sourceType → isIn, GetUserRepositoriesDto page/perPage<1 → min, type out-of-enum → isIn, full-payload accepts); Website-settings (8 tests: CustomMenuItemDto label+path + optional target/icon, target outside _self/_blankisIn, label>50 → maxLength, CustomMenuDto >10 header items → arrayMaxSize, SettingsHeaderDto theme_default outside light/dark/systemisIn, all 3 documented theme literals accepted, SettingsHomepageDto non-bool hero_enabledisBoolean); barrel re-exports (3 tests: smoke check 20 documented runtime classes, ImportSourceTypeEnum re-exported, runtime-symbol count regression guard for silent additions). Highlights an important contract: the class-transformer @Transform decorators fire BEFORE class-validator runs, so sanitize-util truncations silently cap oversized input rather than failing MaxLength — pinned via dedicated tests so a future "switch the order" refactor breaks loudly.
  • account-transfer — 10 source files / 1667 LOC. 165 tests across 6 spec files (2026-05-08, #583): types.spec.ts (24 tests covering MASKED_SECRET_PREFIX literal, maskSecretValue non-string/empty/length-≤8/>8 boundary + first-3-+-last-4 shape, maskSecretSettings null/undefined/non-object defensive coercion + value masking + non-mutation guarantee + empty-input identity, containsMaskedSecrets null/undefined/empty/non-string/substring-not-startsWith/non-string-values/maskSecretSettings round-trip detection); repositories/user-sync-config.repository.spec.ts (12 tests covering findByUser where: { userId } shape + null branch, upsert find→update-then-refetch existing path + repository.create({ userId, ...data }) + save(...) new path + the { userId, ...data } spread-order semantics where caller-smuggled userId DOES override (documented behaviour, protection lives at the calling layer), delete returning affected > 0/affected === 0/missing-affected → false, updateLastPush/updateLastPull setting fresh Date AND clearing lastSyncError to null, updateError writing ONLY lastSyncError w/ verbatim message passthrough including 5 000-char string); account-export.service.spec.ts (45 tests covering User not found rejection, v1 envelope shape (version:1/ISO exportedAt/includesSecrets), profile mapping + falsy-avatar → undefined, zero-works/zero-userPlugins → empty arrays, userPlugins secret-handling matrix (omit when includeSecrets:false, MASKED-only when true, no-attach when secretSettings missing on source, settings null → {}), per-work parallel relation queries via Promise.all (members + customDomains + workPlugins + advancedPrompts + schedule), member/customDomain/workPlugin shape mapping incl. getActiveCapabilities dedup + falsy-strip + MASKED: prefix on workPlugin secretSettings, advancedPrompts conditional emission (omit when ALL 7 fields falsy, include only truthy fields, all-7 case), schedule conditional emission (null → omit, providerOverrides null → undefined coercion), work-level falsy → undefined coercion for owner/deployProvider/readmeConfig/domainType/repoVisibility (with scheduledCadence preserving null as a different rule), fetchWorkRepoData cloning (gitFacade cloneOrPull w/ work-level provider/owner/repo + userId from work.user.id w/ dir.userId fallback when work.user is unloaded), seven data.get* readers in single Promise.all batch, per-reader .catch(() => [] / null) fallback (each one rejected independently → empty result, full export still succeeds), cloneOrPull-rejection AND DataRepository.create-rejection → empty fallback, per-comparison getComparisonMarkdown(slug) attachment + per-comparison-rejection → undefined markdown, markdownTemplate only when at least one of header/footer truthy, siteConfig falsy → undefined coercion w/ real-config passthrough); account-import.service.spec.ts (49 tests covering previewImport payload validation (null/non-object/wrong-version/missing-data/profile/works/userPlugins arrays, version=0 fallback in envelope, partial missing field detection), conflict + missing-plugin detection (per-slug conflicts w/ {slug, existingName, incomingName} shape, deduped missing-plugin list across both userPlugins and workPlugins, totalItemCount aggregation, masked-secret detection in either userPlugins OR workPlugins w/ short-circuit, payload.includesSecrets mirrored onto preview), applyImport transaction lifecycle (User-not-found short-circuit BEFORE queryRunner allocation, connect+startTransaction+commit+release happy path, rollback+release on commit-throw, non-Error transaction failure → String coercion in Transaction failed: message), per-work conflict resolution (skip on missing-resolution and explicit 'skip', 'overwrite' calls workRepository.update(existing.id, ...work-fields) + worksUpdated++, 'rename' w/ explicit newSlug AND fallback to ${slug}-imported when missing, rename-to-occupied-slug → Cannot rename "X" to "Y" - slug already exists + skip, create defaults owner to user.username when dir.owner missing + sets userId from arg, per-work try/catch isolates failures into result.errors while letting subsequent works proceed), userPlugin import (skip-with-warning when plugin row missing, secretSettings omitted when includesSecrets:false, real settings written when includesSecrets:true AND values not masked, masked-values warn-and-skip-secrets w/ ${MASKED_SECRET_PREFIX}... warning copy, per-userPlugin try/catch isolates failures), importWorkRelations (member-userId-not-found warning + skip, isMember:true idempotent skip, addMember positional (workId, userId, role), customDomain idempotent skip + addDomain positional (workId, domain, provider), advancedPrompts emit-only-when-non-empty + skip-on-empty-envelope, scheduleRepository.upsert merges userId from arg verbatim, activeCapabilities ?? (activeCapability ? [activeCapability] : []) legacy-fallback chain w/ both branches pinned, missing-plugin warn+skip, masked workPlugin secretSettings warn+empty-out + real-secret happy path), importWorkRepoData (no-op when no items/comparisons/config/template — does NOT clone, full DataRepository write path w/ markdown stripped from item/comparison + re-written via writeItemMarkdown/writeComparisonMarkdown, getStatus=[] → no commit/push, commit message built from sections with singular-form 1 items, site config from account export, per-work cloneOrPull-rejection → warning not error, work.getRepoOwner?.() legacy fallback to dir.owner then user.username)); github-sync.service.spec.ts (35 tests covering getSyncStatus envelope w/ ISO Date serialisation + invalid-Date filtering + lastSyncError truthy-vs-falsy mapping, configureSyncRepo createNew branch (creates private repo via gitFacade + skips create when private repo exists + REJECTS reuse of public repo + ensureGitHubOAuth gate before any operation), repoFullName branch (rejects malformed 'malformed'/'a/b/c', rejects unreachable repo, rejects public repo, persists owner/repo when private), neither-flag-throws, pushToGitHub (no-config throws Sync not configured, includeSecrets cascades from per-config OR options.includeSecrets, manifest+profile+plugins/user-plugins+per-work files written w/ exact path patterns including works/<slug>/{config,members,domains,plugins,prompts,schedule,site-config,markdown-template,items,categories,tags,collections,comparisons}.json, traversal-shaped '../escape' slug → skip+warn before any file write, getStatus=[] → no commit/push/lastPush update, getStatus non-empty → commit+push+updateLastPush(userId), export-rejection records updateError w/ Error.message and rethrows, non-Error rejection coerced to String), pullFromGitHub (no-config throws, manifest-missing → "No valid configuration found in repository" preview w/o calling previewImport, FORCES payload.includesSecrets = false regardless of in-repo manifest value (defence in depth — secrets are masked in the repo so claiming includesSecrets:true would let import write MASKED:... strings as credentials), cloning-rejection records lastSyncError), applyPull (no-config throws, manifest-missing → success:false noop, updateLastPull ONLY when applyImport returns success:true, FORCES payload.includesSecrets = false, cloneOrPull-rejection records lastSyncError), removeSyncConfig delegates to repository.delete(userId), readExportFiles defensive parsing (corrupt JSON → null → "No valid configuration found" preview, multi-work aggregation walking works/<slug> subdirs w/ path.basename(slug) !== slug filter for traversal slugs)); account-transfer.module.spec.ts (12 tests covering barrel re-exports — module + 3 services + entity + repository + 4 type-helper runtime symbols (mask helpers + prefix), exact-runtime-keys regression guard, @Module() Reflect-metadata pinning providers (3 services + repository), exports (4 user-facing — services + UserSyncConfigRepository), the 3 plugin-related repositories that are re-provided locally for the importer NOT being exported, DatabaseModule+FacadesModule imports by name, TypeORM forFeature dynamic module presence). Mocks '../generators/data-generator/data-repository' (factory exposing DataRepository.create) for the export+import services so the suite does not exercise real fs-extra/isomorphic-git; 'fs' is partially mocked (writeFileSync/readFileSync/existsSync/mkdirSync/readdirSync are jest.fn() while the rest fall through to jest.requireActual('fs') so typeorm's loader still works). Total agent-package suite: 1670 → 1835 tests across 96 suites, all green. Spec already exists at docs/specs/features/data-management/; this row is now closed.
  • items-generator — 18 source files. Schemas covered (#585, 46 tests in schemas/item-extraction.schemas.spec.ts); ItemSubmissionService covered (#593, 64 tests in item-submission.service.spec.ts); DTOs covered (#587, 67 tests in dto/dto.spec.ts) pinning all 10 runtime DTO classes — CheckItemHealthDto (4 tests), RemoveItemDto (6 tests), ExtractItemDetailsDto (7 tests covering protocols:['http','https'] restriction + require_tld:true + per-element string check on existing_categories), UpdateItemDto (6 tests pinning the stricter IsUrl({require_protocol:true}) policy that DIFFERS from the submit-side decorator), SubmitItemDto (15 tests covering the category-OR-categories[] ValidateIf two-branch gate, ArrayMinSize:1 rejection on empty categories array, IsUrl per-element check on images[], Min:0+IsInt on order), ProvidersDto (3 tests — all-empty acceptance + 5 documented provider keys), CreateItemsGeneratorDto (12 tests — including the critical @Transform(sanitizeName/Prompt) runs BEFORE @MaxLength rule so 250-char name is silently truncated to 200 instead of rejected, every GenerationMethod literal accepted, every WebsiteRepositoryCreationMethod literal accepted, nested ProvidersDto validation via @ValidateNested + @Type(() => ProvidersDto), pluginConfig non-object rejection), UpdateItemsGeneratorDto (4 tests — empty payload + nested provider validation), DeleteWorkDto (4 tests — constructor-side false defaults on the four toggles), DeployWebsiteDto (4 tests — both tokens optional, non-string rejection); plus a barrel-exports regression guard pinning the 11 documented runtime classes AND the deliberate exclusion of DeployWebsiteDto (internal-only, not in dto/index.ts barrel by design). Module + barrel covered (#589, 9 tests in items-generator.module.spec.ts) pinning ItemsGeneratorModule providers + exports + 3 imports (DatabaseModule/FacadesModule/PipelineModule), the ItemSubmissionService is the ONLY service exported (generation runs via PipelineOrchestratorService indirectly), the DomainType re-export from @ever-works/contracts (CJS bridge documented in index.ts), and the deliberate "kept thin" 3-imports invariant (no PluginsModule/PipelineOrchestratorModule direct dep). Final piece ([PR pending], 2026-05-08): item-submission.service.ts (631 lines) covered with 64 tests in item-submission.service.spec.tssubmitItem (29 tests covering: workOwner-credential / submitter-committer split on cloneOrPull + push + createPullRequest options forwarding, shouldCreatePR logic precedence (create_pull_request:true overrides pay_and_publish_now AND autoapproval; pay_and_publish_now:true → direct commit; autoapproval:true → direct commit; getConfig() rejection → null → PR fallback), categories[].length>0 wins over category w/ empty-array fallback to single string, slug Transform → slugifyText(name) derivation when missing or empty-string, screenshot capture happy path (URL prepended to images[] not appended) + dedup-when-already-present + skip-when-isAvailable-false + skip-when-source_url-empty + warn-and-continue on capture rejection + skip on success:false, default markdown body shape # <name>\n\n<description>\n\n[<url>](<url>), commit positional (provider, dest, 'Add <name>', work.resolveCommitter(submitter)), PR title format Add <name> - MM/dd/yyyy HH:mm, PR body **Badges:** section emitted only when badges set (with security/license/quality nested fields incl. optional details), **Brand:**/**Brand Logo:** lines emitted only when truthy, **Tags:** joined w/ , (Array.isArray check w/ empty-string fallback), auto_merged: false always pinned in PR mode, direct_commit: true w/ default-branch interpolation in direct mode, error envelope {status:'error', slug, item_name, message} from outer try/catch, switch-to-main rejection on direct-commit re-thrown via "Failed to switch to main branch for direct commit", graceful no-op when getMainBranch() returns null in direct mode); removeItem (12 tests covering: itemExists=false → '<slug>' not found, getItem=null → "Failed to retrieve item details", removeItem=false → Failed to remove item '<slug>', shouldCreatePR branch creates remove-<slug>-<ts> branch + PR, direct-commit branch switches to main, commit message variants Remove <name> - <reason> vs Remove <name>, PR body **Reason:** <reason> only when set, addAll NOT add for staging, switch-to-main rejection swallowed in direct-commit mode (logger.error + null fallback), generic error envelope on cloneOrPull rejection); updateItem (23 tests covering: existingItem=null → '<slug>' not found, getItem rejection caught via .catch(() => null), updateItemMetadata=null → Failed to update item '<slug>', partial-fields-only payload (featured/order/source_url passed through individually, order: null treated as "not provided" via != null guard), sourceUrlChanged semantics (clears health: { status: 'unchecked' } AND source_validation: undefined; identical source_url does NOT clear), commit message variants Update <name> source vs Update <name> metadata, PR branch update-<slug>-<ts> w/ create_pull_request:true, PR title Update source for <name> vs Update <name> based on sourceUrlChanged, PR body Update item source vs Update item metadata prefix + **Order:** n/a for null/undefined order + **Featured:** false via String(!!...) coercion, success envelope w/ source-vs-metadata-flavored message in direct-commit mode, addAll NOT add for staging, push w/ workOwner identity NOT submitter, generic error envelope on cloneOrPull rejection, switch-to-main rejection swallowed in direct-commit mode, skip switchBranch entirely when getMainBranch() returns null and PR is not requested). Total agent-package suite: 1957 → 2021 tests across 100 suites, all green. Closes the agent-package items-generator submodule row — the agent-package zero-coverage gap list is now empty.

Internal-package coverage

  • packages/contracts — vitest scaffolded; 57 tests including a 35-assertion type-level fixture via expectTypeOf plus runtime tests for parseGitHubRepositoryUrl, isTerminalOnboardingStatus, and the DomainType enum (2026-05-07).
  • packages/monitoring — Sentry + PostHog SDKs mocked, 72 tests across config, services, interceptors (2026-05-07).
  • packages/cli-shared — utils + prompt services fully covered (116 tests across slug, validation, config-check, generator-steps, base-prompt.service, work-prompt.service).
  • packages/tasks — vitest scaffolded; 100 tests across LocalPluginStore, TriggerLogger, TriggerService, collectPluginDependencies, TriggerInternalApiClient, task-context.utils, and worker-context.utils (#565) (@trigger.dev/sdk, @ever-works/agent/config, @nestjs/core's NestFactory.createApplicationContext, and the trigger logger factory mocked). The 2026-05-08 follow-up adds 39 tests across three new files: TriggerInternalApiClient (20 tests covering constructor env guards for TRIGGER_INTERNAL_API_URL/TRIGGER_INTERNAL_SECRET, URL composition (trailing-slash strip on base + leading-slash strip on path + URLSearchParams-encoded userId), fetchWorkContext GET shape + x-trigger-secret header + 204/empty-body → undefined, callRemote POST /remote/call with SuperJSON args+meta envelope and SuperJSON-deserialized result, retry behaviour: 5xx retries up to 3× with exponential backoff 500/1000/2000ms pinned via vi.useFakeTimers + advanceTimersByTimeAsync, 4xx never retries, network errors retry up to 3×, non-Error rejections coerced to Error); task-context.utils (9 tests covering hydrator-before-fetch ordering, fetchWorkContext(workId, userId) positional shape, class-transformer plainToInstance(Work|User, …) hydration, work.user = user re-attachment, orchestrator resolution AFTER fetch, gitToken passthrough vs undefined-on-omit, hydrator-error short-circuits the fetch, fetchWorkContext-error short-circuits the orchestrator resolve); worker-context.utils (10 tests covering default TriggerWorkerModule vs caller-supplied module token, useLogger(createTriggerLogger(name)) ordering before fn, fn return-value passthrough, appContext passed to fn, close-on- success / close-on-fn-throw / close-error-propagation / try-finally close-error-overrides-body-error semantics, NestFactory rejection short-circuits fn + close paths). The 2026-05-08 follow-up adds 64 tests across the four task entrypoints (work-generation/work-import/work-onboarding/ work-schedule-dispatcher) — see the dedicated packages/tasks worker task entrypoints row in Done.

API module unit tests

apps/api/src/ modules currently rely heavily on e2e for coverage. Add service-level unit tests (Jest + @nestjs/testing) for:

  • account (14 tests, 2026-05-07) — AccountController thin-controller delegation tests for export/import/sync surfaces; mocks @ever-works/agent/account-transfer.
  • ai-conversation (56 tests, 2026-05-07) — ConversationController (16), ConversationTitleService (15), OpenAiCompatController (4), OpenAiCompatService (21); covers OpenAI-compat DTO mapping, SSE streaming + tool-call delta passthrough, error sanitization (sk-/Bearer redaction), and AI-title generation gating.
  • activity-logJitsuService (9 tests, mocks @jitsu/js, env-driven enable/disable + payload mapping) and ActivityLogListener (25 tests covering all 9 @OnEvent handlers, both happy + error paths) — #482. ActivityLogController covered 2026-05-09 — 33 tests in activity-log.controller.spec.ts covering all five endpoints (getActivities/getRunningCount/getSummary/exportCsv/getActivity) plus the private reconcileActivities helper observable through the endpoints. Pins: in-flight Map per-user dedup (two concurrent callers run reconcile once), 5-second ACTIVITY_RECONCILE_TTL_MS cache (call within 5s skips, call past 5.001s retries), reconcile rejection swallowed AND completedAt NOT cached so the next call retries (vs. successful runs that ARE cached for 5s), per-user isolation (different userIds run independently and forward the correct positional arg), reconcile-before-service ordering pinned via shared order array on every endpoint; getActivities query forwarding (every filter forwarded, ISO dateFrom/dateTo parsed to Date, Math.min(limit, 100) cap incl. boundary limit=100, {activities, total} envelope shape so service-side extras don't leak); getRunningCount {count} envelope; getSummary {counts} envelope; exportCsv filter forwarding + Content-Type: text/csv and Content-Disposition: attachment; filename=activity-log.csv headers + body via res.send, error path skips both header writes and send; getActivity NotFoundException on null lookup (cross-user safety via composite-key findByIdAndUserId), details ?? {} default coercion when activity has no details, live-logs override behaviour (status==='in_progress' AND workId AND work.generateStatus.recentLogs.length>0 → override; otherwise preserve activity.details.liveLogs; null work / no generateStatus / empty recentLogs / missing workId all preserve original; no liveLogs key injected when activity had none and override path didn't fire). Mocks @ever-works/agent/{activity-log,database,entities} so the transitive TypeORM/agent runtime trees aren't pulled in.
  • auth/services/api-key.service (15 tests, 2026-05-07) — ApiKeyService covering create (10-key cap, expiresAt-in-past rejection, ewlive prefix + sha256 hashing, future expiresAt, unique-key generation), list/revoke, and validate (sha256 lookup, null-when-missing, null-when-expired, null-expiresAt = never-expiring, fire-and-forget updateLastUsed swallowing failure).
  • auth/services/auth.service (45 tests, 2026-05-07) — AuthService covering assertCanRegister, validateSocialUser (new user creation with trusted-email emit, unverified-email gating, suspended-account rejection, provider-link bypass, upsertProviderAccount field mapping), sendVerificationEmail (24h expiry, callback URL token-append vs default), verifyEmail, forgotPassword (1h expiry, callback URL handling, generic-message safety), getUserByPasswordResetToken, consumePasswordResetToken, getUser, getUserProfile (sensitive-field stripping, github repo-scope gating, expired-token filtering), updateUserProfile (selective field updates, committer-field clearing semantics), validateEmailVerificationToken, validatePasswordResetToken.
  • auth/services/social-auth.service (37 tests, 2026-05-07) — SocialAuthService covering all four OAuth providers (GitHub/Google/Facebook/LinkedIn): getAuthorizationUrl (default vs override callback+state, Google access_type=offline+prompt=consent, Facebook comma scope separator, LinkedIn default space separator, unknown provider + missing client id rejection); getProviderDisplayName for all four + unknown; getConfiguredProviders (full env, missing client id/secret, empty); authenticate (GitHub full flow w/o grant_type + /user + /user/emails resolution via resolveGitHubAccountEmail, displayName fallback chain login→email-local-part; Google emailVerified default-true vs explicit false, expires_in→expiresAt computation; Facebook always-false emailVerified, picture nesting; LinkedIn OIDC userinfo + given/family fallback + locale metadata; missing access_token, missing email per provider, missing client id/secret, unknown provider; readNumber/readOptionalString edge cases). HttpService mocked with rxjs of().
  • integrations/twenty-crm — 107 tests, 2026-05-07 (utils retry+mapping, config+guard+decorator, services twenty-crm+crm-tenant+client, controllers companies+people) — #498.
  • config — 57 tests, 2026-05-07 (constants.spec.ts 49 + throttler.config.spec.ts 8). Covers the full config object surface (auth/branding/mail/OAuth/githubApp/work/features), authConstants, AuthProvider enum, and the throttler module config tiers — #500.
  • events — 19 tests, 2026-05-07 (events/index.spec.ts). Pins EVENT_NAME strings for all 7 event classes + asserts positional-arg capture and BaseUserEvent inheritance — #500.
  • integrations/github-app controllers — 22 tests, 2026-05-07 (GitHubAppController 15 + GitHubAppWebhookController 7). All 5 controller endpoints + the webhook endpoint covered (setup/callback/listInstallations/syncInstallation/onboardRepository + handleWebhook). Mocks @ever-works/agent/{database,entities,import,services} plus the @src/auth barrel — #502. Service-level coverage previously shipped: github-app.service.spec.ts, github-app-onboarding.service.spec.ts, github-app-sync.service.spec.ts. Follow-up: a github-app.module.spec.ts (Nest module wiring) is the only remaining gap in this folder; defer until apps/api has a precedent for module-level wiring tests.
  • mail/providers/* — 16 tests, 2026-05-07 (FakerMailerService 2 + MailerService 14: SMTP/Resend/faker provider switch, Buffer/template/html/text body resolution, Handlebars template read path, recipient log formatting, constructor provider log)
  • notifications — 14 tests, 2026-05-07 (NotificationsController 10 + NotificationCleanupService 4)
  • onboarding — adapters + well-known controller, 43 tests, 2026-05-07 (OnboardingAccountAdapter 18 + OnboardingWorkAdapter 18 + WellKnownController 7). Covers GitHub-app login → user resolution chain (link → provider account → email → create), username sanitization/uniqueness/UUID-fallback, provider account + github link upsert with fields and metadata, error swallowing on upsert paths; Work-adapter manifest → CreateWorkDto translation incl. slug truncation/fallback/sanitization, deployProvider default, owner extraction (.git strip, malformed URL → ''), description fallback, missing-id rethrow, describeError fallback; agent-card env overrides (PUBLIC_API_URL/MCP_URL/DOCS_URL/CONTACT_EMAIL).
  • subscriptions (9 tests, 2026-05-07) — SubscriptionsController getPlan (enabled-false envelope, plan mapping, error propagation) + updatePlan (BadRequest when disabled, mapping, response source = assignPlanToUser, error propagation) — #496.
  • trigger (13 tests, 2026-05-07) — TriggerInternalController getWorkContext (secret + userId guards, gitToken null → undefined, password/user-relation stripping) + callRemote (superjson Date round-trip, unknown target/method, secret short-circuit, 10 remote targets, no shared state) — #496.
  • templates — currently only Handlebars views (*.hbs); no controller/service to unit-test directly. Coverage flows through MailerService.sendMail template-resolution path (already covered in #492). Could add a snapshot test for compiled-template output if regression risk emerges.
  • API root (apps/api/src/api.controller.ts + apps/api/src/logging.interceptor.ts) — covered 2026-05-09. APIController (8 tests in api.controller.spec.ts): success envelope shape, analyticsService.track('anonymous', 'api_home_visit', {endpoint:'/', timestamp}) emission, ISO-8601 timestamp formatting via fake timers, current "track-throws-propagates" behaviour pinned (no try/catch at controller level — flagged so any future fire-and-forget refactor breaks loudly), no-userId-no-email leak in payload, healthCheck delegates to home() returning the same envelope AND the same analytics event (no monitoring suppression at controller level — Sentry/PostHog noise filtering happens at the monitoring-package layer). LoggingInterceptor (12 tests in logging.interceptor.spec.ts): debug-off short-circuit on unset HTTP_DEBUG, literal-'false', and '1' (only literal-'true' enables — pins the strict-equal check), debug-on success path emits Incoming Request: <method> <url> synchronously then Outgoing Response: <method> <url> <statusCode> - <delay>ms with statusCode-200 fallback when response.statusCode is falsy, debug-on error path's three-tier statusCode fallback (err.response.statusCodeerr.response={} falls back to 400 → no err.response falls back to 500), elapsed-ms via paired Date.now() (verified with jest.useFakeTimers()), Incoming-Request line still emitted before the error fires, original error rethrown via throwError(() => err) (rejects.toBe(err) identity-preservation), and logger.error/logger.log cross-exclusion (success path never errors, error path never logs the Outgoing-Response line). Mocks ./auth (Public decorator) and @ever-works/monitoring for the controller; the interceptor spec stubs the underlying logger via jest.spyOn on the private logger field.
  • plugins-capabilities/{device-auth,git-provider,oauth} — 85 tests, 2026-05-07 (DeviceAuthService 6 + DeviceAuthController 4, GitProviderService 15 + GitProviderController 9, OAuthService 29 + OAuthController 22). Pins all checkConnection branches (unknown / no-credentials / oauth shape / PAT shape / falsy-accessToken fallback / getUser-throws), success/Error/non-Error envelope shape on getOrganizations/getRepositories/getUser, full handleOAuthCallback upsert payload incl. plugin: prefix and expiresAt arithmetic, BadRequestException wrap semantics on getConnectUrl (Error vs non-Error), forceConsent strict-'true' parsing, missing-code BadRequest before service call. Mocks @ever-works/agent/{facades,database,plugins} plus ../../auth#518.
  • plugins-capabilities/{search,screenshot} controllers — 41 tests, 2026-05-07 (SearchController 17 + ScreenshotController 24). Pins all checkAvailability provider-resolution branches (x-envVar/x-adminOnly skip, default-first sort, three-tier sort for screenshot, fallback chain for active provider), full facade-call positional shape, error-wrapping semantics (NoProviderErrorBadRequestException with capability-specific message; generic Error → original message; non-Error → generic message), and result-shape envelopes ({status:"success", ...} happy path, {status:"error", message} BadRequest payload). Mocks @ever-works/agent/{facades,plugins} plus ../../auth, with NoProviderError re-exported from the facades mock so instanceof checks in the controller still match — #520.
  • plugins/{plugins.controller,plugin-validation.service} — 55 unit tests, 2026-05-08 (PluginValidationService 20 + PluginsController 35). Validation service covers tryValidateConnection (null on missing/unloaded/no-validation-hooks, validateConnection success + workId forwarding, BadRequestException coercion incl. object-with-modelResults, string body via NestJS auto-wrap, "Validation failed" fallback, non-BadRequest warn-and-null, 20s timeout warn-and-null via jest.useFakeTimers, git-provider branch via gitFacade.getUser({userId, providerId}), isAvailable fallback with success + isAvailable=false → BadRequest coercion with credentials message), validateUserPluginConnection throwing alias (NotFoundException on missing/unloaded, validateConnection success + BadRequestException with {message, modelResults} on success:false, git-provider Connected message, isAvailable=false BadRequest, isAvailable=true verified message, generic "settings saved" fallback when no hooks present). Controller covers all 13 endpoints — listing (listPlugins w/ undefined-category passthrough, getPluginsForSettingsMenu, listPluginModels, getPlugin), user management (enablePlugin positional (pluginId, userId, settings, secretSettings, autoEnableForWorks) w/ undefined-passthrough + PLUGIN_ENABLED log + log fire-and-forget rejection swallowing + no-log-on-service-reject; disablePlugin PLUGIN_DISABLED log + no-log-on-reject; updatePluginSettings calls tryValidateConnection WITHOUT workId at user level (asserted via mock.calls[0] length), PLUGIN_CONFIGURED log, validation:null pinned, no-log-no-validation on service-reject; setGlobalPipelineDefault pluginId ?? null coercion, undefined-enforce passthrough; validatePluginConnection propagates throwing-alias errors), work management (listWorkPlugins ensureCanView BEFORE service via shared order array, skip-on-reject; enableWorkPlugin ensureCanEdit BEFORE service, OBJECT-shaped {settings, activeCapability, priority} payload w/ all-undefined passthrough, work.plugin_enabled log w/ workId, no-log on either ensureCanEdit reject OR service reject; disableWorkPlugin work.plugin_disabled log; updateWorkPluginSettings calls tryValidateConnection WITH workId (3-arg form), work.plugin_configured log, no-log-no-validation on either ensureCanEdit OR service reject; setActiveCapability ensureCanEdit BEFORE service, NO activity log emitted — pinned via not.toHaveBeenCalled(), skip on ensureCanEdit reject). Mocks @ever-works/agent/{services,plugins,activity-log,facades,entities} plus ../auth so the agent runtime tree is not pulled in — closes the only remaining HIGH-priority API module gap.
  • works/* (largest surface — split by sub-module): tasks/_ schedulers, members.controller, dto/_, and all works.controller.ts endpoint groups (CRUD, generation, schedule, items, domain+repo, advanced-prompts+import, taxonomy CRUD, comparisons + community-pr + source-validation) now covered.
    • works/tasks/* — 59 tests, 2026-05-07 — full schedulers surface: CommunityPrSchedulerService (6), ComparisonSchedulerService (7), ItemSourceValidationCronService (5), WebsiteTemplateSchedulerService (10), WorkCacheWarmupService (11), WorkCleanupService (11), WorkScheduleDispatcherCronService (9) — #504.
    • works/members.controller.ts — 15 tests, 2026-05-07 (all 6 endpoints: listMembers, inviteMember, getMember, updateMemberRole, removeMember, leaveWork) — #506.
    • works/dto/* — 30 tests, 2026-05-07 (InviteMemberDto, UpdateMemberRoleDto, ValidateTokenDto, BatchDeployDto, BatchDeployItemDto, GenerateWorkDetailDto, GenerateManualComparisonDto) — #506.
    • works/works.controller.ts (1716 lines) — split per endpoint group across multiple PRs (all sub-batches now complete; full controller covered):
      • CRUD batch (get/list/stats/create/update/delete + cached items/config/count/categories-tags + website-settings + history + website-templates) — 29 tests, 2026-05-07. Covers query-param parsing (NaN/0/empty-string/undefined → undefined; limit=0 short-circuit pinned), cacheManager.wrap usage with the four work-cache keys + WORK_CACHE_TTL_MS, activity-log fire-and-forget on update/delete/website-settings (success + rejection swallowing + no-log-on-reject), updateWebsiteSettings cache invalidation via cacheEntryRepository.typeormAdapter.deleteUnscopedEntriesLike, getWebsiteTemplates explicit-isDefault preservation + global-default fallback when no template advertises isDefault.
      • generation + cancellation batch (generateItems / updateItemsGenerator / cancelGeneration / getGlobalGeneratorFormSchema / getGeneratorFormSchema / generateWorkDetails) — 17 tests, 2026-05-07. Covers generateItems IN_PROGRESS GENERATION log + awaitCompletion=false + log-rejection swallowed + log fires before service call (so service rejection does NOT skip the log); updateItemsGenerator IN_PROGRESS update_started log + OBJECT-shaped payload ({workId, updateDto, user, awaitCompletion}, not positional); cancelGeneration no-log + error propagation; getGeneratorFormSchema runs ensureAccess(id, user.id) BEFORE getFormSchema (invocation order pinned, no schema lookup on access denial); getGlobalGeneratorFormSchema does NOT include workId in the form context (only userId); generateWorkDetails positional (work_name, prompt, user, ai_provider) w/ undefined ai_provider passthrough.
      • schedule batch (getWorkSchedule / updateWorkSchedule / cancelWorkSchedule / runScheduledUpdate) — 14 tests, 2026-05-07. Covers: getWorkSchedule wrap envelope; updateWorkSchedule branching on runImmediately && status === ACTIVE (immediate runScheduledUpdate fired ONLY in that case, not when paused, not when runImmediately=false), SCHEDULE_UPDATED log, fire-and-forget runScheduledUpdate failure logging w/ Error.stack vs String(error) variants; cancelWorkSchedule SCHEDULE_DELETED log + no-log-on-reject; runScheduledUpdate BadRequestException on non-ACTIVE status (no log, no service call), {status:'pending', slug, message} envelope w/ work.slug fallback to id, SCHEDULE_EXECUTED log, runScheduledUpdate failure logging w/ Error.stack vs String(error).
      • items mutations batch (submit-item / remove-item / update-item / check-item-health / extract-item-details / bulk-capture-images) — 14 tests, 2026-05-07. Covers: submitItem ITEM_ADDED log w/ Added item: ${name || 'New item'} summary fallback + invalidateWorkCaches, no-log/no-invalidate on service rejection; removeItem ITEM_REMOVED log + invalidate; updateItemMetadata ITEM_UPDATED log w/ details {itemSlug, featured, order} (undefined passthrough for missing fields) + invalidate; checkItemHealth positional (id, item_slug, user) forwarding to itemHealthService.checkItem, ITEM_UPDATED item.source_rechecked action, summary fallback to dto item_slug when result.item_name missing, full result-fields log details + invalidate; extractItemDetails forwards (dto, user) w/ NO log + NO cache invalidation; bulkCaptureImages ITEM_UPDATED items.images_captured log w/ NO cache invalidation (asserted explicitly — pinning the existing behavior).
      • domain + repo batch (domain-type / regenerate-markdown / update-readme / update-website / switch-website-template / sync-data / repositories/visibility GET+PUT) — 23 tests, 2026-05-07. Pins: updateDomainType positional (id, domainType, user, manuallySet) w/ manuallySet ?? true default + explicit false override + NO activity log on success or error; regenerateMarkdown/updateReadme SETTINGS_UPDATED log w/ actions work.markdown_regenerated/work.readme_updated + log fire-and-forget rejection swallowed + no-log on service rejection; updateWebsiteRepository WEBSITE_SETTINGS_UPDATED + work.website_updated (no-log on reject); switchWebsiteTemplate summary mapping for ALL 4 switchMode values (no_change → service-provided result.message verbatim, saved_for_initialization → "Saved … for first website creation", repository_reset → "reset the existing website repository", repository_recreated → "recreated the website repository") + full details payload (previous/new template id + switchMode + repositoryRecreated + repository) + null websiteTemplateId clear semantics + no-log-on-reject; syncWorkData ordering pinned via mockImplementation w/ shared callOrder array (sync FIRST, invalidate AFTER) + WORK_UPDATED work.synced_from_data_repo log w/ {syncStatus, updatedFields, message} details, no-log/no-invalidate on reject; getRepositoryVisibility runs ensureAccess(id, user.id) BEFORE getRepositoriesStatus(work, user) (invocation order asserted via invocationCallOrder, NO log emitted, ensureAccess rejection short-circuits the status call); updateRepositoryVisibility runs ensureAccess BEFORE updateRepositoryVisibility(work, user, repoType, isPrivate) (positional args + invocation order pinned, NO log, both data+website repo types covered) — #513.
      • advanced-prompts + import batch (advanced-prompts GET+PUT, import analyze/analyze-for-linking/import/repositories) — 19 tests, 2026-05-07. Pins: getAdvancedPrompts forwards (id, auth.userId) DIRECTLY (skips authService.getUser, NO log); updateAdvancedPrompts forwards (id, dto, auth.userId) (also skips authService.getUser — pinned), PROMPTS*UPDATED work.prompts_updated log + log-rejection swallowed + no-log-on-reject. analyzeRepository/analyzeForLinking forward (dto, user) w/ NO log + propagate errors; importWork forwards (dto, user), IMPORT work.import_started log w/ {sourceUrl, sourceType, gitProvider} details (undefined passthrough when fields omitted) — and pinned that the log payload has NO workId (import is global, not work-scoped); getUserRepositories query-param parsing — parseInt(*, 10)for page+perPage (each independently undefined-able), empty-stringsearch/ownercoerced to undefined via|| undefined, typepassthrough for both'user'and'org', NO activity log.
      • taxonomy CRUD batch (categories create/update/delete, tags create/update/delete, collections create/update/delete) — 21 tests, 2026-05-07. Pins for ALL 9 endpoints: positional service args (id, [entityId,] dto, auth.userId); cache invalidation runs AFTER the service call (asserted via shared order array w/ mockImplementation push); SETTINGS*UPDATED log with the entity-specific action (taxonomy.<entity>*<verb>) and entity-specific summary (create includes the dto name interpolated, update/delete uses generic verb-only summary); update logs include details: { <entity>Id, name }w/ undefinednamepassthrough for PATCH-style updates; delete logs includedetails: { <entity>Id }; service rejection short-circuits BOTH cache invalidation AND log emission; fire-and-forget log-rejection swallowing asserted on three sample endpoints (createCategory / deleteTag / updateCollection) so the cross-cutting pattern is pinned for every taxonomy endpoint shape — #515.
      • comparisons + community-pr + source-validation batch (process-community-prs, comparisons list/remaining-count/generation-status/get/generate/generate-manual/delete, source-validation GET+PUT) — 23 tests, 2026-05-07. Pins: processCommunityPrs NotFoundException on workRepository.findById null + BadRequestException on !work.communityPrEnabled (both throw BEFORE service call w/ no cache invalidation and no log); happy path forwards work (full entity) to processor + COMMUNITY_PR_MERGED log w/ details: { itemsAdded } + {itemsAdded} envelope return. Comparisons: listComparisons/getRemainingComparisonCount/getComparison run access via workQueryService.getWork(id, user) BEFORE service call (invocation order pinned), getRemainingComparisonCount returns {count} envelope, getComparison throws NotFoundException on BOTH null AND undefined result.comparison; getComparisonGenerationStatus calls authService.getUser but does NOT call workQueryService.getWork (asserted explicitly — no access check); generateNextComparison/generateManualComparison/deleteComparison run ensureCanEdit(id, user.id) BEFORE the service call (invocation order pinned); generateManualComparison BadRequestException when itemASlug equals itemBSlug (no service call, no log, but ensureCanEdit STILL ran); manual-flavored summary Generated comparison: <a> vs <b> with details {status, slug, itemASlug, itemBSlug, message}deleteComparison log has slug-interpolated summary and NO details field (pinned via not.toHaveProperty('details')). Source-validation: getSourceValidationSettings runs ensureAccess BEFORE getCadenceAllowances BEFORE getSettings, NO log; updateSourceValidationSettings runs ensureCanEdit BEFORE getCadenceAllowances BEFORE updateSettings, SETTINGS_UPDATED work.source_validation_updated log w/ details {cadence, enabled} (undefined passthrough), no-log-on-reject. This closes the per-endpoint-group split for works.controller.ts — every endpoint in the controller now has unit-test coverage.

Pending — Medium Priority

Spec Kit features that need a spec

Cross-check docs/specs/features/ against actual capabilities. Candidates known to lack a Spec Kit feature folder:

  • auth-jwt-oauth (JWT issuance, OAuth GitHub/Google flow) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/auth-jwt-oauth/. Covers all 35 functional requirements: 14 endpoints across AuthController, 2 across OAuthController, 3 across ApiKeysController, the four-mode AuthSessionGuard (public-route short-circuit / API-key via x-api-key OR Authorization: Bearer ew_live_* / Better Auth cookie / 401), the dual session-store design (Better Auth credentials + the platform's auth_session table with 7-day TTL), all four OAuth providers (GitHub w/o grant_type + /user/emails resolution, Google w/ access_type=offline + prompt=consent, Facebook always-emailVerified=false + comma scope-separator, LinkedIn given/family fallback), validateSocialUser reconciliation rules (suspended → 401, untrusted email + no existing link → 401, trusted-email new user → emit UserConfirmedEvent), 32-byte hex verification + reset tokens (24h / 1h expiry, atomic clearPasswordResetToken), API-key SHA-256 hashing w/ 10-key per-user cap and fire-and-forget updateLastUsed, the iss='ever-works' vs iss='auth-runtime' split for API-key vs. session paths, and the fire-and-forget user.login/user.login.<provider> activity-log emission via .catch(() => {}). Outstanding follow-ups: T33 (AuthProvider-impl-level coverage), T34 (request-headers.toHeaders direct unit), T36 (provider-instance unit) — service-level coverage shipped via #486 / #488; T30 (AuthController), T31 (OAuthController), T32 (ApiKeysController), T35 (AuthSessionGuard) shipped 2026-05-08 with the auth-controllers + guard PR (4 spec files / 85 tests), T37 (OAuth state verification — currently passed-through but unverified, see OQ-2), T38 (gate verificationToken/resetToken echo behind NODE_ENV !== 'production', OQ-3), T39 (document iss split, OQ-1), T40 (signOutAll cookie-session purge, OQ-5), T41 (per-key API-key scopes).
  • notifications (in-app + email delivery, channels) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/notifications/. Covers per-user delivery, deduplication via deduplicationKey w/ DB-level partial-unique race recovery (Postgres 23505 / MySQL ER_DUP_ENTRY / SQLite SQLITE_CONSTRAINT), persistent-notification refusal of dismissal, all 6 controller endpoints behind AuthSessionGuard, cross-user reject via findByIdAndUserId, cleanup cadence (expired / 7-day-dismissed / 30-day) under a DistributedTaskLockService lock, and the 5 producer convenience methods.
  • subscriptions (plan/feature gating) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/subscriptions/. Covers all 18 functional requirements: three-plan seed (FREE/STANDARD/PREMIUM) idempotently upserted on onModuleInit, two-endpoint controller (GET /api/subscriptions/plan envelope w/ enabled-false → plan: null; POST /api/subscriptions/plan w/ @IsEnum(SubscriptionPlanCode) validation + BadRequestException('Subscriptions are disabled') BEFORE any user lookup), SubscriptionService.resolvePlanForUser four-level chain (active subscription → user.defaultPlanconfig.subscriptions.getDefaultPlanCode()FREE fallback w/ warn log), getCadenceAllowances projection (seven-cadence grid w/ payPerUse: !allowed flag and Upgrade to <Premium|Standard|Free> for this cadence recommendation), requiresUsageBilling truth table (disabled → false, cadence-allowed → false, otherwise billingMode !== USAGE), assignPlanToUser with normalizePlanCode lowercase fallback to 'free' + NotFoundException('Plan not found') on missing row + in-place user.defaultPlan mutation, UsageLedgerService.recordUsage short-circuit (subscriptions-disabled OR billingMode !== USAGEnull), ledger-write shape (units = 1, amountCents = max(0, round(PAY_PER_USE_PRICE_USD * 100)), currency from BillingProvider.getDefaultCurrency(), metadata: { cadence: schedule?.cadence }) + BillingProvider.recordUsageCharge fan-out (default ManualBillingProvider no-op), WorkScheduleService consumer integration (cadence gate via requiresUsageBilling w/ "Switch to pay-per-use to continue." message, PLAN_LIMIT_EXCEEDED when countActiveByUser >= plan.maxWorks, markRunCompleted ledger write with triggerType: SCHEDULED, markRunFailed/markRunSkipped no-write). Outstanding follow-ups: T18/T41 (restore production FREE cadence gate [MONTHLY] — currently ALL_CADENCES for "everything is free for now", OQ-1), T39 (Postgres-container integration test for the full ledger pipeline), T40 (e2e test against the controller — currently controller-level unit only), T42 (decide on SETTINGS_UPDATED activity-log emission on plan change, OQ-2), T43 (wrap billingProvider.recordUsageCharge in try/catch + mark entry 'failed' for retry, OQ-3), T44 (default UserSubscription.billingProvider to MANUAL until Stripe lands, OQ-4), T45 (decide on assignPlanToUser refetch-vs-mutate, OQ-5).
  • activity-log (event taxonomy + persistence) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/activity-log/. Covers all 18 functional requirements: per-user audit trail w/ optional workId (ON DELETE SET NULL), six endpoints behind AuthSessionGuard (list w/ Math.min(limit, 100) cap, running-count, summary, CSV export w/ attachment; filename=activity-log.csv + 10 000-row cap, :id w/ details.liveLogs enrichment for live generation rows), lazy reconcileActivities debounced via per-user in-flight Map + 5-second recently-completed cache, fire-and-forget Jitsu dispatcher (env-gated, plain-object metadata gate, rejection MUST NOT throw out of log()/updateStatus()), WorkGenerationCompletedEvent in-place update via findLatestByUserWorkActionStatus so a single user×work×run never produces two rows, and the listener-side try/catch + logger.error policy.
  • mail-providers (provider abstraction + templates) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/mail-providers/. Covers all 15 functional requirements: seven @OnEvent listeners (signup/welcome/password-changed/forgot-password/new-device-login/account-deletion/member-invitation), env-driven provider routing (MAILER_PROVIDER switch with SMTP/Resend/Faker default-on-anything-else), graceful degradation (Resend without RESEND_APIKEY → Faker fallback w/ warn log), per-call construction + send + sent log lines, getDestination normalisation for string|Address|array-of-mixed, readHtmlTemplate resolution chain ({cwd}/src/templates/<slug>.hbs via Handlebars → data.html Buffer/string → data.text Buffer/string → ''), branding context merge ({appName, companyOwner, platformWebsite, currentYear}) on every template, formatDateTime (Intl.DateTimeFormat('en-US', …)) + formatRoleName (capitalise first / lowercase rest), per-handler try/catch + logger.error so delivery failures NEVER propagate to the originating event, default dashboardUrl = ${webAppUrl}/works/new, default expiresIn = '1 hour' for forgot-password and '24 hours' for account-deletion, and the MailerModule Handlebars adapter wiring (inlineCssEnabled: true, strict: true, tls.rejectUnauthorized: false SMTP gate flagged as an audit follow-up). Outstanding follow-ups: T24 (operator-facing devops doc) — T21 (listener-side unit suite, 28 tests, #552) and T22 (Resend to=undefined crash fix) both shipped 2026-05-08.
  • templates-catalog (PR #459 just landed — needs spec backfill) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/templates-catalog/. Covers all 24 functional requirements: built-in seed on onModuleInit (Classic always, Minimal env-gated via WEBSITE_TEMPLATE_MINIMAL_REPO) wrapped in try/catch + warn-log; seven HTTP endpoints behind global AuthSessionGuard (GET /api/templates, POST /api/templates/custom, PUT /api/templates/custom/:templateId, POST /api/templates/custom/:templateId/archive, PUT /api/templates/default, POST /api/templates/fork, POST /api/templates/refresh); GitHub-discovery pass gated by 1-hour TTL (WEBSITE_DISCOVERY_SYNC_TTL_MS) with 50×100-page safety cap, *template-suffix filter (/template$/i), canonical-vs-discovered id reconciliation (findBuiltInByRepositoryCoordinates wins; otherwise repo.name.toLowerCase()), and duplicate-id deactivation branch; addCustomTemplate URL parsing via parseGitHubRepositoryUrl + BadRequest('Only valid GitHub repository URLs are supported for custom templates.') + duplicate Conflict('You already added this template repository.') + 'main'/[branch]/humanizeRepositoryName(repo)/inferFrameworkFromRepository defaults; updateCustomTemplateForUser "undefined → preserve, empty-string → null/preserve" rules incl. syncBranches rewrite when branch changes; archiveCustomTemplateForUser singular-vs-plural Conflict copy for assigned-works AND inheriting-default-works refusal, soft delete via isActive=false + preference-row removal; forkTemplateForUser six error classes (NotFound for invisible/kind-mismatch, BadRequest for non-built-in/empty-target/unavailable-target, BadRequest on failed gitFacade.forkRepository) + existing-fork short-circuit (matches (kind, userId, targetOwner, repositoryName), re-adopts as default, returns created: false, NO template.forked log) + happy path with seven metadata.forkedFromX audit fields + auto-set as default; fire-and-forget activity-log emission via .catch(() => {}) for the FIVE mutating endpoints (template.added/updated/archived/default_set/forked-when-newly-created); getDefaultTemplateIdForUser four-level fallback (preference → visible-row check → getDefaultWebsiteTemplateId() for website / null for work); originType projection (standard/forked/custom_url). Outstanding follow-ups: T35 (Postgres-container integration test for the full add→default→archive cycle), T36 (e2e against /api/templates* w/ real GitHub stub), T37 (generalise providerId='github' literal in fork flow + discovery, OQ-1), T38 (decide kind:'work' discovery convention, OQ-2), T39 (HTTP HEAD reachability check on repositoryUrl, OQ-3), T40 (skip seed-default fallback when archived, OQ-4), T41 (decide on template.refreshed activity log, OQ-5).
  • ai-conversation (chat-style conversations against works) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/ai-conversation/. Covers all 23 functional requirements: per-user Conversation + ConversationMessage entities w/ composite-key indexes and cascade deletes, all 7 /api/conversations/* endpoints behind global AuthSessionGuard (auth.userId-only identity, 404-on-cross-user via composite-key repo filter), first-message title derivation (/\s+/g, ' ' collapse + 60-char cap), fire-and-forget AI-title generation gated on messageCount >= 4 && !metadata.aiTitle w/ summary windowing + temperature: 0.3 + maxTokens: 30 + 100-char cap + parts-fallback for empty content, sequential appendMessages w/ explicit createdAt = baseTime + i for cross-driver ordering, the OpenAI-compat surface at POST /api/v1/chat/completions (permissive ValidationPipe({whitelist:true}) w/o forbidNonWhitelisted, four streaming SSE headers + data: [DONE]\n\n terminator, model === 'auto'undefined for facade resolution, three-shape message mapper, tool-call delta first-vs-continuation id/type/name rules required by @ai-sdk/openai-compatible's id == null continuation parser, pre-headers 502 JSON envelope vs post-headers res.destroy(error), regex-based secret redaction in sanitizeErrorMessage). Tasks file lists 19 shipped tasks across 8 phases plus 10 outstanding follow-ups (OQ-1..OQ-7 + e2e, Postgres-container integration test, and the chat/completions doc under docs/api/).
  • integrations-twenty-crm — spec / plan / tasks authored 2026-05-08 at docs/specs/features/integrations-twenty-crm/. Covers the global Nest TwentyCrmModule (forRoot/forRootAsync with @Global()), the env-var gate (TWENTY_CRM_BASE_URL + TWENTY_CRM_API_KEY + TWENTY_CRM_WORKSPACE_ID, optional timeout/retries/delay), CrmConfigService + CrmSyncGuard (warn-and-deny when disabled, error-and-deny on validateConfig throw), TwentyCrmService.makeRequest URL composition (/rest<endpoint> vs /rest/metadata<endpoint> when schema=true, X-Workspace-Id: <id || 'default'>, bearer auth, HttpException wrap with upstream message + details OR 503 for transport failure), ClientService 16-method surface (4 entities × create/get/update/delete + 4 list helpers, all proxying via PUT for updates), CrmTenantService three-level fallback (work_<workId>globalTenantId'global_everworks') and /tenants/<tenantId> endpoint prefix, RetryUtils.withRetry exponential back-off w/ no-sleep on maxAttempts=1 + isRetryableError (ECONNRESET/ETIMEDOUT/ENOTFOUND/5xx/429), MappingUtils four mappers + four validators (USD-default currency, NEW-stage 50%-probability deals, single-token name → firstName-only, URL parse with https:// prefix fallback to undefined). Tasks file lists 17 shipped tasks across 8 phases (PR #498 covers the 107 unit tests) plus 12 outstanding follow-ups (OQ-1..OQ-9 + e2e + plugin-migration + create-company-id audit). Documents PeopleController-as-dead-code (missing @Controller decorator, not registered in module), the PATCH-controller-vs-PUT-service mismatch, and the asymmetric whitelist policy across the two controllers.
  • integrations-github-app — spec / plan / tasks authored 2026-05-08 at docs/specs/features/integrations-github-app/. Covers all 30 functional requirements: the non-@Global() GitHubAppModule (imports DatabaseModule/HttpModule/AuthModule/WorkModule/ImportModule; exports GitHubAppService/GitHubAppOnboardingService/GitHubAppSyncService); the five required env vars (GITHUB_APP_ID/GITHUB_APP_CLIENT_ID/GITHUB_APP_CLIENT_SECRET/GITHUB_APP_PRIVATE_KEY w/ \\n rewrite/GITHUB_APP_WEBHOOK_SECRET) + three optionals (slug default 'ever-works', setup/callback URL defaults derived from webAppUrl); GitHubAppService (getUserAuthorizationUrl w/ blank-string client_id fallback, exchangeUserCode POST /login/oauth/access_token w/ Unauthorized on data.error and BadRequest when no token, getAuthenticatedGithubUser via resolveGitHubAccountEmail, getInstallation w/ app JWT, createInstallationAccessToken proxying to requestGitHubAppInstallationAccessToken, paged listInstallationRepositories w/ per_page=100 break-on-short-page or total_count met, verifyWebhookSignature short-circuit-false on missing secret, hard Error from getCredentials when GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY missing); GitHubAppOnboardingService (HMAC-SHA-256 + base64url state w/ 10-minute TTL + constant-time timingSafeEqual after length-guard, redirectTo normalised to leading-/ only, findOrCreateLocalUser four-step chain (user-link → auth-account → email lookup with verified-email-required gate → create with bcrypt-hashed UUID password + @users.noreply.ever.works synthetic email when verified email missing + registrationProvider:'github'), username uniqueness via -2/-3/... suffix loop with 'github-user' empty-base fallback, auth_accounts upsert w/ tokenType:'Bearer' + metadata:{nodeId, providerUserId, login}, distinct github_app_user_links upsert (separate token store from auth_accounts because GH-App OAuth and social-OAuth are separate apps), re-getInstallation + re-upsertFromGithub + claimOwnershipIfUnassigned w/ BadRequest('GitHub App installation could not be persisted') when claim returns null); GitHubAppSyncService (listInstallationsForUser fan-out via Promise.all to listForInstallation, syncInstallation short-circuit on missing/deletedAt/suspendedAt/createdByUserId !== userId, replaceForInstallation w/ selected:true default + owner-fallback to accountLogin + fullName fallback to '<owner>/<repo>', onboardInstallationRepository w/ data_repo-only gate + WorkImportService.onboardLinkedRepository handoff w/ mode:'github_app_installation' auth payload carrying installation+repo IDs, handleWebhook for installation (deletedmarkDeleted(<id>, new Date()); suspendsuspendedAt = new Date(); unsuspendsuspendedAt = null; created/new_permissions_accepted/unsuspend → re-sync; created ALSO captures createdByGithubUserId from payload.sender?.id) and installation_repositories (re-sync w/ no userId filter), silent return on unsupported events / missing installation id); GitHubAppController (@Public setup/callback, query DTOs w/ class-validator (installation_id required + setup_action ∈ {install, request}; code+state required), callback issues session via AuthProvider.issueSession and returns {...auth, installationId, redirectTo} envelope, listInstallations/syncInstallation/onboardRepository inherit global AuthSessionGuard, null from sync → 401 Unauthorized, null from onboard → 404, {status:'error', message} from onboard → 400 BadRequest); GitHubAppWebhookController (@Public POST /api/github-app/webhooks w/ 400-on-missing-event-header, 400-on-missing-rawBody, 401-on-bad-signature, returns {ok:true}); race-safety guarantee from claimOwnershipIfUnassigned's WHERE createdByUserId IS NULL UPDATE; webhook signature verification via crypto.timingSafeEqual-backed verifyGitHubWebhookSignature. Plan documents the non-Global module choice, the nestjs/axios HTTP shape, the agent-package JWT helpers, the config.auth.secret() reuse for HMAC state, the global raw-body parser dependency for webhook signature verification, the claimOwnershipIfUnassigned race-safety pattern, and a 9-row risk table. Tasks file lists 14 shipped tasks across 7 phases (covering integrations/github-app/*.ts + the three agent-package entities/repositories) plus 16 outstanding follow-ups: T15 (OQ-1) promote getCredentials Error to ServiceUnavailableException, T16 (OQ-2) add GitHubAppSyncGuard mirroring CrmSyncGuard, T17 (OQ-3) friendly setup-error UI for unverified-email rejection, T18 (OQ-4) derive synthetic noreply suffix from webAppUrl(), T19 (OQ-5) decide second-user-installs-same-app semantics, T20 (OQ-6) log unsupported webhook event names at debug, T21 (OQ-7) decide on activity-log emission for installation lifecycle events, T22 (OQ-8) move "data_repo only" message to constant/i18n, T23 (OQ-9) preserve prior selected:false rows during replaceForInstallation, T24 Postgres-container integration test for the three repositories' atomicity, T25 e2e against /api/github-app/webhooks with crafted signatures, T26 github-app.module.spec.ts, T27 GitHub Enterprise Server support via GITHUB_APP_BASE_URL/GITHUB_APP_API_BASE_URL, T28 bulk repo-onboarding via Trigger.dev, T29 allow non-data_repo types from the GitHub-App surface, T30 OpenAPI doc tightening for the public setup/callback envelopes. Closes the integrations-github-app row from COVERAGE-TRACKER.md "Pending — Medium Priority".
  • plugins-capabilities (#539) — spec / plan / tasks authored 2026-05-08 at docs/specs/features/plugins-capabilities/. Pins the user-facing HTTP contract over the six capability sub-modules (deploy, search, screenshot, oauth, git-provider, device-auth) under apps/api/src/plugins-capabilities/. Spec covers all 54 functional requirements split across cross-cutting (FR-1..FR-5: global AuthSessionGuard, four-envelope shapes, facade-only resolution, four-level settings cascade with x-secret/x-envVar rules, WorkOwnershipService.ensureCanEdit/.ensureCanView with owner-vs-caller userId switching), Deploy (FR-6..FR-25: getAvailableProvidersForUser per-user list, four-flag /configured envelope ({configured, available, enabled?, message}), full ensureCanEdit → isConfigured → validateToken → deploy → startVerification → activityLogService.log chain, four required Actions secrets (TENANT_ID/DATA_REPOSITORY/<PROVIDER>_TOKEN/DEPLOY_TOKEN) + one variable (DEPLOY_PROVIDER defaults to 'vercel') + two optional secrets (DEPLOY_TEAM_SCOPE, GH_TOKEN) + always-fresh 32-byte hex CRON_SECRET, optional plugin contract methods getDeploymentSecrets(settings) (failures caught + logged + dispatch continues) and getWorkflowFilenames() (fallback ['deploy_prod.yaml']), one-time retry path (update repo → write .deployment-trigger → commit + push → 3 000 ms wait → retry), DeploymentDispatchedEvent ONLY on success, BatchDeployDto.works[] rolling batches of 5 with 2 000 ms sleep + per-item ensureCanEdit BEFORE service call + startVerification only for pending results + status coercion failed===0→success / successfullyStarted>0→partial / else→error, verifier 10-second poll with FETCH_LIMIT=18 + 13-min wall-clock cap + terminated boolean idempotency guard preventing duplicate terminal events + CANCELED-on-replace, domain endpoints short-circuit on empty work.website + DTO-level regex ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$), Search (FR-26..FR-31: per-call resolveConfiguredProvider via pluginRegistry.getEnabledPluginsScoped(SEARCH, undefined, userId) w/ defaultForCapabilities-first sort, required-settings check skips x-envVar/x-adminOnly and treats undefined/null/"" as missing, two distinct messages — "Search plugins are enabled but none have all required settings configured (e.g. API key)." vs "No search provider is enabled. Enable a search plugin (e.g. Tavily, Linkup, Brave, Exa) in settings.", providerOverride forwarding to facade, NoProviderError"No search provider configured. Enable a search plugin in settings.", non-Error → "Search failed"), Screenshot (FR-32..FR-39: three-tier sort default→configured→name w/ getDefaultForCapabilityScoped checked first then defaultForCapabilities flag then systemPlugin, available true iff ≥1 configured, full DTO forwarding + {userId, workId, providerOverride} context, cacheUrl preferred over imageUrl, imageBuffer.toString('base64') only when present else null, NoProviderError re-mapping + non-NoProviderError rethrown, null/empty imageUrl"Failed to generate screenshot URL"), OAuth (FR-40..FR-48: {configured, providers} envelope, findConnectedProviderAccount(userId, providerId, {usePluginProviderId: true}) for connection check, connectionSource derived from providerId plugin: prefix, randomBytes(16).toString('hex') default state, strict-'true' parse on forceConsent (only literal 'true' enables), missing-code BadRequest BEFORE service call, handleOAuthCallback upserts w/ buildPluginProviderId + accountId=oauthUser.id + accessTokenExpiresAt=now+expiresIn*1000 + metadata:{oauthUserId, name, avatarUrl}, DELETE returns 204 undefined, service errors wrapped into {success:false, error} inside 200 — NEVER 4xx for getUser/getOrganizations/getRepositories/disconnectProvider), Git Provider (FR-49..FR-52: /api/git-providers plural base path, /connection two-branch resolution (OAuth shape {providerId, token: accessToken} vs PAT shape {userId, providerId}) + authMethod derived ('oauth' even if user-resolution fails), parseInt(_, 10) independently undefined-able page/perPage, same 200-envelope-with-error contract as OAuth), Device Auth (FR-53..FR-54: thin two-line passthroughs to pluginOperationsService.getPluginDeviceAuthStatus/startPluginDeviceAuth, positional (pluginId, userId) order — NOT (userId, pluginId), errors propagate unwrapped). Plan documents the agent-package facade boundary (DeployFacadeService/SearchFacadeService/ScreenshotFacadeService/OAuthFacadeService/GitFacadeService from @ever-works/agent/facades), the per-capability sub-module mounting in api.module.ts (lines 18-71), the WorkOwnershipService ownership-vs-caller userId swap pattern, the EventEmitter2 decoupling between deploy services and ActivityLogListener (no hard dep on ActivityLogService), the in-memory setInterval queue keyed by workId w/ terminated boolean idempotency, the github-plugin-mediated GitHub Actions secrets API (libsodium-encrypted via setActionSecret), the rolling-batches-of-5 with 2 000 ms sleep concurrency cap, the no-Trigger.dev decision (verification ≤13 min naturally fits a single-process poller), the no-activity-log decision for search/screenshot/OAuth/git-provider/device-auth (per-call CRUD-style traffic), and a 9-row risk table including the API-restart-loses-verification gap. Tasks file lists 33 shipped tasks across 7 phases (covering all six modules + controllers + services + plugin contract evolution + event-driven activity-log wiring + per-controller spec coverage from PRs #518 / #520) plus 10 outstanding follow-ups (T-DEPLOY-CTRL closed 2026-05-08, #600, 54 tests across all 14 deploy controller endpoints — see Done section): T-OQ-1 (decide fate of placeholder POST /api/deploy/teams), T-OQ-2 (unify error-envelope asymmetry — deploy/search/screenshot throw 4xx vs OAuth/git-provider/device-auth wrap into 200), T-OQ-3 (Trigger.dev migration for DeploymentVerifierService so verification survives API restarts), T-OQ-4 (expose providerOverride from SearchDto mirroring screenshot), T-OQ-5 (decide on activity-log emission on validateToken rejection), T-OQ-6 (per-controller @Throttle for batch + capture endpoints), T-DOCS-1 (docs/api/plugins-capabilities/{deploy,search,screenshot,oauth,git-providers,device-auth}.md per-capability pages), T-DOCS-2 (architecture diagram in docs/architecture/plugins-capabilities.md), T-E2E-1 (plugins-capabilities e2e w/ stubbed registry), T-E2E-2 (kind-cluster CI scenario w/ throwaway repo). Closes the only remaining Pending — Medium Priority Spec Kit feature gap.
  • community-pr-processing — refreshed 2026-05-08 to match current implementation. Original spec (2026-05-01) was significantly outdated: (a) listed wrong triggeredBy enum values ('api' | 'cron' | 'webhook' vs the actual 'user' | 'schedule' | 'api'), (b) claimed invalid items trigger PR comments (no such code path — extraction failures are silent and surface only as lastError), (c) claimed duplicates close the PR with a comment (actually duplicates are silently skipped at slug-level via data.itemExists(slug) check), (d) listed wrong endpoint path /api/works/:id/community-pr/process (actual /api/works/:id/process-community-prs), (e) missed the processedPrs array (with per-PR updatedAt + outcome) which lets re-runs reprocess updated PRs, (f) missed the three constants (MAX_PROCESSED_PR_NUMBERS=500, MAX_CHANGE_CONTEXT_LENGTH=50000, COMMUNITY_PR_LOCK_TTL_MS=30 minutes), (g) missed the FIFO eviction at 500 entries, (h) missed the recordCommunityPrHistory writes to work_generation_history with WorkHistoryActivityType.COMMUNITY_PR_MERGED, (i) claimed state persistence per-PR (actual: per-work after the inner loop), (j) missed the workRepository.increment(work.id, 'itemsCount', N) after successful items, (k) missed the extractedItemSchema zod shape (name/description/source_url/category/tags), (l) missed the AI call shape (aiFacade.askJson(prompt, schema, {temperature:0.3}, {userId, workId})), (m) missed the controller's owner gate via workQueryService.getWork, the BadRequestException on !communityPrEnabled, the cache invalidation, and the fire-and-forget activityLogService.log({actionType: COMMUNITY_PR_MERGED, action: 'community_pr.processed', summary, details: {itemsAdded}}) emission, (n) missed the scheduler's outer runExclusive('works:community-pr-scheduler', fn, {ttlMs: 60*60*1000}) lock + try/catch + Starting/completed log lines. Refreshed spec.md (15 functional requirements, full edge-case enumeration), plan.md (12-cell tech-choices table + 9-row risk table + full mermaid diagram showing both trigger paths AND the per-work lock + outer scheduler lock + history write + activity-log emission), tasks.md (13 shipped tasks across 6 phases plus 13 outstanding follow-ups: T14 (OQ-1) surface AI failures back to PR contributor, T15 (OQ-2) drop unused 'user' triggerSource, T16 (OQ-3) emit IGNORED-status history rows for visibility, T17 (OQ-4) drop legacy processedPrNumbers array, T18 (OQ-5) track per-PR cost / token usage, T19 (OQ-6) renew lock or time-box on long runs, T20 Postgres-container integration test, T21 e2e on the manual endpoint, T22 @Throttle guard, T23 Trigger.dev migration, T24 surface lastProcessedAt/lastError/totalItemsAdded in UI, T25 review queue UI, T26 monitoring alarm on stale lastProcessedAt). Closes the community-pr-processing audit entry from COVERAGE-TRACKER.md "Pending — Medium Priority".

Docs gaps to audit

  • docs/packages/* — every plugin now has a README; audit completed 2026-05-08 (#544). All 42 plugin READMEs already cover settings (## Settings section listing each schema field + scope + secret/envVar markers), capabilities (Plugin metadata table), and example configuration ("Getting started" steps). Added missing ## Troubleshooting section to all 42 plugins via scripts/add-plugin-readme-troubleshooting.py (a one-shot, idempotent generator that templates per category — search / content-extractor / screenshot / ai-provider / deployment / git-provider / data-source / pipeline / utility — using package.json#everworks.plugin metadata to fill in id, name, capability list, and PLUGIN_<ID>_* env var fallbacks). Each section is a 4–6 row table covering authentication errors (401/403/key revoked → re-enter + env-var fallback), rate limits (429 → throttle / upgrade), capability-specific empty results (broaden query / filter / depth tuning), default-provider selection (plugin not used during X → set as default in Settings → Plugins), lifecycle errors (healthCheck reports unhealthy → curl + firewall), plus per-plugin specifics where applicable (Notion object_not_found means page not shared with integration, PDF garbled-text means pre-OCR needed, GitHub Enterprise → set custom API Base URL, Vercel deployment stuck BUILDING → check four required Actions secrets TENANT_ID/DATA_REPOSITORY/<PROVIDER>_TOKEN/DEPLOY_TOKEN, k8s ImagePullBackOff → verify imagePullSecret + registry reachability, Claude Managed Agents 403 → enroll in managed-agents-2026-04-01 beta, Hermes command not found → install on host + set binaryPath, claude-code/codex/gemini/opencode CLI subprocess errors → install CLI on PATH, make/zapier/sim-ai/activepieces webhook 404 → activate scenario + refresh URL). Also fixed agent-pipeline (incorrectly templated as webhook-based — replaced with maxSteps exhaustion guidance). Env vars are documented per-setting in each plugin's ## Settings section (PLUGIN_<ID>_<SETTING> pattern, surfaced from x-envVar schema extension); 25 plugins use x-envVar and the remaining 17 expose only user-scoped/secret settings without env-var fallback by design.
  • docs/api/* — endpoint reference; cross-check against apps/api/src/**/controllers/*.controller.ts (2026-05-08). Three new pages added: docs/api/activity-log.md, docs/api/template-catalog.md, docs/api/account.md covering the previously-undocumented modules. Sidebar (apps/docs/sidebarsPlatform.ts) and docs/api/index.md "Endpoint Groups" table updated. Remaining gaps live under apps/api/src/{onboarding,trigger} — the trigger surface is internal (Trigger.dev → API JWT-secret callback) and intentionally NOT user-documented; the onboarding surface is captured implicitly via the well-known controller, which hosts the public agent-card endpoint and could be a follow-up well-known.md page.
  • docs/architecture/* — audit completed 2026-05-08 (#547). No stale directory/directories references remain after the Directory→Work rename (PR #436) — grep -ri 'director' docs/architecture/ returns zero matches. Refreshed docs/architecture/agent-package.md to reflect the post-#456 module map: bumped the export count from 20 → 27 and added the six newly-shipped sub-modules (./onboarding, ./constants, ./activity-log, ./works-config, ./template-catalog, ./account-transfer); flagged the legacy ./git export as a re-export from facades/git.facade.ts + utils/git-repository.utils.ts (the standalone src/git/ directory no longer exists); updated the source-tree ASCII diagram to add the seven new directories (account-transfer/, activity-log/, onboarding/, template-catalog/, types/, works-config/, and the moved work-operations/); updated the service-layer table from 14 → 19 entries (added WorkWebsiteRepositoryStateService, WorksManifestService, ItemHealthService, ItemSourceValidationSchedulerService, StateMarkerService, WebhookDeliveryService). WorksManifestService is the post-#456 canonical works.yml parser/writer surface, so the rename is now reflected in the diagram. Other architecture pages (pipeline-system, event-system, events-deep-dive, generator-system, etc.) do not reference the legacy directory.yml shape and need no edits.
  • docs/devops/* — added 2026-05-08 (#547) at docs/devops/k8s-e2e-runbook.md (registered in apps/docs/sidebarsPlatform.ts at sidebar position 15, after devops/kubernetes). Documents the .github/workflows/k8s-e2e.yml job (#464): the paths: filter that gates the workflow on packages/plugins/k8s/** so docs-only PRs don't pay the ~5-minute cluster-provisioning cost; the four-cell test matrix (v1.28.15 × v1.30.13 × ingress=false × ingress=true); the pinned kindest/node:v1.30.13 and controller-v1.11.3 ingress-nginx images and why they are pinned (deterministic state across runs); the six-test e2e suite (validateConnection, ensureNamespace idempotency, applyDeployment+applyService convergence, listManagedDeployments, getDeployment-null, getIngressLoadBalancerHost-null) and what each one catches that mocks cannot; the KUBECONFIG_E2E_PATH-gated describe.skip pattern; the full local-reproduction recipe (kind create → ingress-nginx apply + wait → kind load docker-image → kubeconfig export → pnpm --filter '@ever-works/k8s-plugin...' buildpnpm --filter @ever-works/k8s-plugin test:e2ekind delete); the on-failure debug hooks (kubectl get nodes / ingressclass / all -A | head -100 + kubectl describe deployment -A | head -200); a 5-row triage table mapping common failure modes to the right next step; and the operating envelope (one test image, one namespace per run, no persistent volumes, no external network). Cross-links to the workflow source, the k8s-deployment Spec Kit feature, the plugin README, kind release notes (for bumping node images), and ingress-nginx releases (for bumping the pinned controller tag).

Pending — Low Priority

  • CLI (apps/cli) — vitest scaffolded 2026-05-08 (#555); 72 unit tests added across utils/{jwt.utils,error,wait,constants} (31 tests), commands/auth/credentials.service (29 tests), and services/http-client (12 tests). Establishes the per-module src/**/__tests__/*.spec.ts layout and the pnpm test / pnpm test:watch scripts (the legacy test:cli smoke-test — cli --help against the built bundle — is preserved verbatim because CI still calls it). Mocks fs-extra for credentials, axios (create() factory + interceptors.{request,response}.use capture) for the HTTP client, and ../../commands/auth.getCredentials to keep the request-interceptor suite hermetic. Pins: JWT base64url decode w/ -/_ translation + bad-JSON console.error path, isJWTExpired no-exp-claim → false (treat as never-expires), getJWTUserInfo AuthUser-shape projection, CredentialsService.get() five-step validation chain (file-missing / non-object / missing-or-non-string-token / not-3-dot-parts / expired) with file-removal at every rejection branch + readJson-throw + remove-also-throws double-failure path, CredentialsService.update no-op when get() returns null, createWithExpiry email-override + ISO expiresAt, getTokenExpiryInfo largest-unit-only projection (days OR hours OR minutes; never two simultaneously) + expiresAt fallback when JWT lacks exp, requireAuth process.exit(1) on missing creds. HttpClient covers /api-suffix normalisation (append / preserve / no-double-slash), 30-second timeout + JSON Content-Type defaults, all five HTTP verbs proxied with positional args, request interceptor injects Authorization: Bearer <token> from credentials AND rewrites baseURL when stored credentials.apiUrl differs from constructor arg, request-error passthrough, response-error 401 → process.exit(1) + still re-rejects, non-401 passthrough, getHttpClient() singleton. Closes the CLI low-priority gap.
  • Internal CLI (apps/internal-cli) — vitest scaffolded 2026-05-08 (#561); 115 unit tests added across 10 spec files. Establishes the per-module src/**/__tests__/*.spec.ts layout and the pnpm test / pnpm test:watch scripts (the legacy test:cli smoke-test — cli --help against the built bundle — is preserved verbatim because CI still calls it). Coverage: ConfigService (32 tests — file-IO via fs-extra mocks, loadConfig null-when-missing + parse-error wrapping, saveConfig ensureDir-then-writeJson order + 2-space indent + undefined/null/empty-string stripping + ensureDir-failure vs writeJson-failure error-wrapping branches, mergeConfig new-wins-on-conflict + missing-existing-as-empty, validateConfig 4 required-git-field errors + 6 AI-provider key alternatives + Tavily-warning gating on EXTRACT_CONTENT_SERVICE/WEB_SEARCH_SERVICE, loadConfigIntoEnv String-coercion + null/undefined skip + GITPROVIDER='github' default + GIT_TOKEN→GH_APIKEY + GIT_OWNER→GH_OWNER mirroring with no-overwrite-when-set semantics); constants (3 tests — COMMAND/COMMAND_ALIAS literal pinning); handleCliError (11 tests — null/undefined/false short-circuit, custom-header arg, string-error path, response.data.message extraction, error.message fallback, DEBUG_CLI=true raw-error logging vs DEBUG_CLI≠true silent, Owner-is-required git-tip, status===404 work-vs-resource branching, primitive-error String() coercion); ConfigCheckService (6 tests — null-config / validation-failure / valid-config / loadConfig-throws branches + requireConfiguration exit-only-on-failure); AiProviderRegistryService (12 tests — exactly 7 default providers registered, requiresApiKey:false ONLY for ollama+custom, ollama localhost baseUrl default, custom empty-models open-ended, choices-without-ignore vs choices-with-ignore + non-mutation guarantee); SetSubCommand.run (22 tests — <2 params rejected, lowercase/leading-/leading-digit key rejected, AI_DEFAULT_PROVIDER 8-value case-insensitive whitelist, EXTRACT_CONTENT_SERVICE tavily/local + WEB_SEARCH_SERVICE tavily-only, _TEMPERATURE [0,2] range + NaN, _MAX_TOKENS [1,200000] range + NaN, _BASE_URL URL parse, GIT_EMAIL regex, multi-word value space-join, merge-without-clobber, API-key masking head-4+stars+tail-4 with full key NOT in clear text, short-value no-mask, saveConfig-rejection swallowed); UnsetSubCommand.run (13 tests — null/empty-config no-op, missing explicit key rejection, confirm:true removes-then-saves, confirm:false aborts, critical-key warning matrix for all 6 documented keys, interactive-prompt fallback, prompt-cancel selectedKey:null, orphaned-related-keys after _API_KEY removal, AI_FALLBACK_PROVIDERS warning after AI_DEFAULT_PROVIDER removal, error-during-loadConfig swallowed); SwitchAiSubCommand.run (6 tests — null-config / no-providers / per-key detection / Ollama-via-BASE_URL-special-case / loadConfig-throws / all-six-providers detected); ServeCommand Option parsers (7 tests — parsePort 1..65535 range + non-numeric rejection + parseInt-strips-trailing-non-digits behavior; parseHost trim + empty/whitespace rejection); LocalEventEmitterModule (4 tests — @Global metadata, factory provider returns fresh EventEmitter2 per call, exports declares EventEmitter2, basic emit/on round-trip on factory-produced emitter). Mocks fs-extra (ensureDir/writeJson/readJson/pathExists), inquirer ({prompt, Separator}), and @ever-works/cli-shared (displayConfigurationError) to keep all suites hermetic. Build remains green: tsconfig.build.json excludes **/__tests__/** and vitest.config.ts so nest build is unaffected.
  • Admin app (apps/admin) — once routes stabilize.
  • MCP server (apps/mcp) — audit completed 2026-05-08 (#548). The 7 prior spec files (api-client.service, api-error, mcp-config, openapi-loader, sanitize, schema-converter, tool-registration) cover the OpenAPI-tool registration pipeline, the API client, and config / sanitisation utilities. Added 4 new spec files (33 tests) to cover the four remaining uncovered surfaces: ping.tool.spec.ts (4 tests — single text content envelope w/ pong body, fresh-envelope-per-call no-shared-state, sync-not-Promise return), health.controller.spec.ts (3 tests — {status:'ok'} envelope, fresh-object-per-call, sync return), api-key.guard.spec.ts (9 tests — EVER_WORKS_API_KEY unset / empty-string falsy / request.headers undefined / Authorization-missing / wrong-scheme Basic … / wrong-key value / case-mismatched bearer … (strict equality, NOT case-insensitive) / trailing-whitespace token / exact-match Bearer <key> happy path), register-work.tool.spec.ts (17 tests — 2xx envelope shape with pretty-printed JSON, default https://api.ever.works API base when EVER_WORKS_API_URL is unset, trailing-slash strip on EVER_WORKS_API_URL, X-GitHub-Token header forwarding (token MUST NOT appear in body), Idempotency-Key header conditional on idempotencyKey provided, body excludes githubToken and idempotencyKey, POST + Content-Type/Accept JSON headers, empty-body 2xx → {} parse, non-JSON 2xx → {raw} fallback, non-2xx → isError:true envelope w/ HTTP <status> text, non-JSON error body → {raw} in error envelope, fetch-rejection Error → Ever Works API unreachable: <message>, non-Error rejection → String(err) coercion, GitHub token NEVER appears in error envelopes (auth-error AND fetch-rejection paths both asserted), all-optional-fields-omitted body shape). Total MCP coverage: 11 spec files / 95 tests, all passing.
  • Performance / load tests for the standard pipeline (15 steps).
  • Visual-regression for marketing pages once those settle.

Conventions for new tests added by this task

  • Per-plugin spec lives in packages/plugins/<id>/src/__tests__/<id>.plugin.spec.ts.
  • Mocking style: prefer mocking the upstream SDK or global.fetch over network round-trips. No nock, no live calls.
  • Assert at minimum: id/name/version/category/capabilities, settings schema (required keys, secret/envVar markers), happy-path search/extract, error path (missing API key, non-OK HTTP), lifecycle (onLoad/onUnload), healthCheck, getManifest.
  • Run locally with pnpm --filter @ever-works/<id>-plugin test before committing.
  • Branch naming: tests/<area>-<short-slug> (e.g. tests/search-plugins-zero-coverage).
  • PR title: conventional test(<scope>): <summary>. Body: list of plugins/modules covered + a one-line summary of mocked surfaces.
  • Always merge to develop without waiting for human review (the scheduled-task spec authorizes this).

Follow-ups discovered

  • API test suite has 2 pre-existing failures on develop from a stale @ever-works/agent dist/. As of 2026-05-07, running pnpm --filter ever-works-api test fails 2 suites (template-catalog/template-catalog.controller.spec.ts and plugins-capabilities/deploy/deploy.service.spec.ts) with TS2339/TS2305 errors against packages/agent/dist/entities/activity-log.types.d.ts (missing TEMPLATE_ADDED/TEMPLATE_UPDATED/TEMPLATE_ARCHIVED/TEMPLATE_DEFAULT_SET/TEMPLATE_FORKED) and packages/agent/dist/generators (missing WebsiteTemplateResolverService export). The 350 other tests pass. Likely fix: rebuild the agent package (turbo build --filter=@ever-works/agent) in CI / locally — but a real fix probably means adjusting apps/api's Jest config or the agent package.json exports so source is preferred over stale dist. Not a regression — observed before #496.
  • MailerService.sendMail Resend branch crashes when to is omitted. At apps/api/src/mail/providers/mailer.service.ts:48, to: this.getDestination(data.to) is unguardedfixed 2026-05-08 by short-circuiting the call site to to: data.to ? this.getDestination(data.to) : [] so the Resend branch now mirrors the SMTP and faker branches' tolerance of a missing to field. The corresponding assertion in mailer.service.spec.ts was flipped from "rejects with 'address' in TypeError" to "forwards to: [] to resend.emails.send". Originally discovered while writing #PR-mail-providers (2026-05-07).
  • WorkGenerationService pipeline-orchestrator helpers need a depth-of-coverage follow-up sweep. The 2026-05-09 sweep #6 covered the wrapper / simpler methods (95 tests across updateDomainType / regenerateMarkdown / updateReadme / updateWebsiteRepository / extractItemDetails / bulkCaptureImages / cancelGeneration / runScheduledUpdate / submitItem / removeItem / updateItemMetadata / generateItems smoke / updateItemsGenerator config-load + skip + per-run-default branches). The in-process pipeline orchestrators in packages/agent/src/services/work-generation.service.ts remain to be exhaustively unit-tested: processGeneration (87 lines, AbortController wiring + log collector + three-step finalization), executeGenerationPipeline (60 lines, dataGenerator → markdownGenerator → websiteGenerator chain w/ throwIfGenerationCancelled between steps + non-fatal "repository not found" website warning), finalizeGeneration (45 lines, Promise.all of work-status + history-update + schedule-finalize), runInProcessGeneration (20 lines, error-wrap), markGenerationStarted (18 lines, GENERATING status + history-update), finalizeCancelledGeneration (38 lines, three-step CANCELLED finalize + WorkGenerationCompletedEvent emit), handleErrorNotification (16 lines, classification dispatch via notifyForClassifiedError), prepareProviders (21 lines, three-step generatorFormSchemaService chain), ensureProvidersEnabledForWork (43 lines, per-uiKey isPluginEnabledForScope check + auto-enable + silently-skip-on-error), dispatchGenerationTask (55 lines, dispatcher fallback to in-process w/ schedule-vs-user awaitable behaviour), isNonFatalWebsiteGenerationError (12 lines, 'repository not found' substring check), resolveGenerationFinalStatus / resolveGenerationErrorMessage / buildScheduleRunOutcome (small pure helpers). Covering these requires richer mocks for dataGenerator.initialize (returns {success, stats, warnings, prUpdate, hasExistingItems, error} envelope w/ optional cause), markdownGenerator.initialize (signal-aware), websiteGenerator.initialize (signal-aware), and exercising EventEmitter2-emitted WorkGenerationCompletedEvent. Estimated +60-80 tests in a follow-up sweep.