Skip to content

test: add server-free unit tests for note, path and cursor logic - #1966

Open
karlitschek wants to merge 4 commits into
mainfrom
test/noid/add-unit-tests
Open

karlitschek wants to merge 4 commits into
mainfrom
test/noid/add-unit-tests

Conversation

@karlitschek

@karlitschek karlitschek commented Aug 6, 2026

Copy link
Copy Markdown
Member

The suite so far has been integration-only: everything under tests/api/ boots a real Nextcloud, so there was no way to exercise the app's pure logic without a server, and no test at all covered the functions that turn user input into file names.

composer.json already declared a test:unit script pointing at tests/unit/phpunit.xml, but neither the config nor PHPUnit itself was present. This makes that script real:

  • tests/unit/phpunit.xml + bootstrap.php — no server, no database, no web server. composer test:unit works on a bare checkout. The bootstrap declares OC\Hooks\Emitter, which OCP\Files\IRootFolder extends but nextcloud/ocp does not ship, the same way tests/stubs/ocp.php already fills gaps for Psalm.
  • phpunit/phpunit and doctrine/dbal as dev dependencies. DBAL is needed because mocking OCP\IDBConnection reflects over IQueryBuilder, whose signatures reference Doctrine's types; the server provides it at runtime.
  • OCA\Notes\ is mapped in autoload-dev — the app relies on Nextcloud's own app autoloader, which is absent outside a server.
  • A separate phpunit-unit.yml workflow so these run on every pull request in seconds, independently of the server-backed test.yml.

81 tests covering NoteUtil (category-path normalisation including traversal attempts, title derivation, collision-safe file names, markdown stripping), NotesService (which files count as notes, the folder walk including hidden files and attachment folders, titles from content), Note (title, category, excerpt, BOM and object-storage content handling), Util::retryIfLocked and ChunkCursor.

Changes since the first review

Rebased onto current main, which needed two fixes on its own: NotesService::__construct has gained a fourth parameter and gatherNoteFiles() a $showHidden argument since this branch was opened.

Addressing @enjeck's review:

  • Trimmed the suite from 121 tests to 81, and dropped the characterization tests that pinned known-bad behaviour in long docblocks. Three of the behaviours they described are real bugs and are fixed below; the other two — a non-positive maxRetries, and an empty custom file extension — describe states no caller can reach, so they are simply gone. Also dropped: a duplicate cursor round-trip, tests of trivial getters, and the thinner rows of several data providers.
  • Removed the explanatory comments throughout, including the workflow header flagged inline. What is left in tests/unit/ is a single inline comment — object storage returning false rather than '' for an empty file — plus two short docblocks describing what a helper builds.
  • Deduplicated the NoteUtil setup that had been copy-pasted across three test classes into tests/unit/NotesTestCase.php.
  • Added tests/unit/ to php_dirs so make lint covers it the way it already covers tests/api/.

One side effect worth knowing about: installing doctrine/dbal lets Psalm resolve Doctrine's classes for the first time, and it immediately flagged a pre-existing deprecated Table::setPrimaryKey() call in a migration. That is recorded in tests/psalm-baseline.xml rather than worked around, since the call is what Nextcloud's own migration API expects.

Two bugs the tests turned up

Both are long-standing, neither is introduced here, and each is fixed in its own commit with a regression test.

Nested categories were silently dropped. gatherNoteFiles() merged the recursion's category list into the parent's with the + array union. Both operands are sequentially-keyed lists, so the union keeps the left-hand value at every occupied index and discards the rest — which subcategory was lost depended on its position, not its depth. Confirmed against a local Nextcloud 36 instance, where the sidebar received 7 categories although 16 exist. The files union beside it is keyed by file id, which is unique across the tree, so no note was ever lost, and the v1 API discards this list entirely, so there is no client-facing change.

Excerpts mis-stripped multi-byte titles. getExcerpt() passed a character count to strncasecmp(), which counts bytes, so content that merely began with the same first character as the title was mistaken for a repeated title and truncated. On the same instance, a note titled 日本語 with the body "日曜日 is Sunday" was excerpted as "is Sunday". The same commit replaces empty($title) in getSafeTitle(), which made a note whose first line is exactly 0 come out titled "New note".

Separately, make lint had one phpcs error left in it — a stray blank line before the closing brace of SettingsService — which is now gone too, so the target passes cleanly.

These three commits widen what was a test-only PR. They are small and self-contained, so say the word if you would rather review them separately and I will split them out.

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@karlitschek
karlitschek requested review from enjeck and a lite review from Copilot and removed request for enjeck and silverkszlo August 6, 2026 12:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a server-free PHPUnit unit test suite for the app’s pure logic (note/title/category/path normalization, folder walking, cursor parsing, retry-on-lock behavior), making composer run test:unit runnable on a bare checkout without a Nextcloud instance.

Changes:

  • Introduces unit-test infrastructure under tests/unit/ (phpunit config + bootstrap stubs) and adds comprehensive unit tests for key logic classes.
  • Adds dev dependencies and autoload-dev mappings needed to run app classes and mock OCP interfaces outside a server.
  • Adds a dedicated GitHub Actions workflow for fast unit-test execution on every pull request, and wires unit tests into the Makefile.

Reviewed changes

Copilot reviewed 10 out of 12 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/unit/Service/UtilTest.php Unit tests for Util::retryIfLocked() retry and rethrow behavior.
tests/unit/Service/NoteUtilTest.php Unit tests for category-path normalization, safe title derivation, markdown stripping, and collision-safe filename generation.
tests/unit/Service/NoteTest.php Unit tests for deriving title/category/excerpt/content behavior from mocked file nodes (incl. BOM/object-storage edge cases).
tests/unit/Service/NotesServiceTest.php Unit tests for folder-walk logic determining note files/categories and title derivation from content.
tests/unit/Controller/ChunkCursorTest.php Unit tests ensuring chunk cursor round-trips and rejects malformed inputs.
tests/unit/phpunit.xml PHPUnit config for server-free unit tests (bootstrap, strictness, suite discovery).
tests/unit/bootstrap.php Minimal stubs/bootstrap to allow mocking OCP interfaces that extend server-internal OC interfaces.
.github/workflows/phpunit-unit.yml CI workflow to run unit tests quickly on PRs across the Nextcloud PHP version matrix.
composer.json Adds PHPUnit/DBAL dev deps and autoload-dev mappings for app + unit tests.
composer.lock Locks the added dev dependency graph for PHPUnit/DBAL and transitive packages.
Makefile Adds test-unit target and runs unit tests as part of make test.
.gitignore Ignores PHPUnit cache/result cache artifacts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@enjeck enjeck left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't looked deeply yet, but a brief glance shows there are too many tests and I don't think they are all useful. Having too many makes CI slower and harder to maintain. I think we should strip this down to a few necessary tests that test core Notes functionality.

Also, there are too many long comments. I would expect the code to be readable enough to not need so many comments unless where there are "gotchas" to be aware of

Comment thread .github/workflows/phpunit-unit.yml Outdated

@enjeck enjeck left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

@AndyScherzinger AndyScherzinger added 2. developing tests AI assisted This PR contains AI-assisted commits labels Sep 16, 2026
@AndyScherzinger AndyScherzinger added this to the 6.2.0 milestone Sep 16, 2026
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The suite so far has been integration-only: everything under tests/api/
boots a real Nextcloud, so there was no way to exercise the app's pure
logic without a server, and no test at all covered the functions that
turn user input into file names.

composer.json already declared a `test:unit` script pointing at
tests/unit/phpunit.xml, but neither the config nor PHPUnit itself was
present. This makes that script real:

* tests/unit/phpunit.xml + bootstrap.php — no server, no database, no web
  server. `composer test:unit` works on a bare checkout. The bootstrap
  declares OC\Hooks\Emitter, which OCP\Files\IRootFolder extends but
  nextcloud/ocp does not ship, the same way tests/stubs/ocp.php already
  fills gaps for Psalm.
* phpunit/phpunit and doctrine/dbal as dev dependencies. DBAL is needed
  because mocking OCP\IDBConnection reflects over IQueryBuilder, whose
  signatures reference Doctrine's types; the server provides it at
  runtime.
* OCA\Notes\ is mapped in autoload-dev — the app relies on Nextcloud's
  own app autoloader, which is absent outside a server.
* A separate phpunit-unit.yml workflow so these run on every pull request
  in seconds, independently of the server-backed test.yml.

The tests cover NoteUtil (category-path normalisation including traversal
attempts, title derivation, collision-safe file names, markdown
stripping), NotesService (which files count as notes, the folder walk,
titles from content), Note (title, category, excerpt, BOM and
object-storage content handling), Util::retryIfLocked and ChunkCursor.

No production code is touched by this commit.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
gatherNoteFiles() merged the recursion's category list into the parent's
with the `+` array union. Both operands are sequentially-keyed lists, so
the union keeps the left-hand value for every index that is already
occupied and discards the rest: a subcategory was dropped whenever the
parent's list had already reached that index.

Which ones were lost therefore depended on position rather than depth —
for a folder 'Work' containing A, B and C, 'Work/A' collided with 'Work'
at index 0 and vanished while its siblings survived.

The visible effect is a nested folder without notes of its own missing
from the sidebar, because the frontend derives categories from the notes
themselves and only consults this list for folders that have none. The
files union above it is keyed by file id, which is unique across the
tree, so no note was ever lost.

array_merge() appends instead of filling gaps, which is what the
recursion needs.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Two title comparisons treated a string as a byte sequence or as a
boolean, and both are wrong for real note content.

getExcerpt() decided whether the content repeats the note's title by
handing a character count to strncasecmp(), which counts bytes:

    $length = mb_strlen($title, 'utf-8');        // characters
    strncasecmp($excerpt, $title, $length)       // bytes
    mb_substr($excerpt, $length, null, 'utf-8')  // characters

For an ASCII title the two agree. For a multi-byte one strncasecmp only
ever compared the first few bytes, so content that merely started with
the same character was mistaken for a repeated title and that many
characters were cut off. A note titled 日本語 whose body is
"日曜日 is Sunday" was excerpted as "is Sunday". Comparing a
case-folded prefix of the same character length fixes it and keeps the
case-insensitive match the byte version provided.

getSafeTitle() guarded its fallback with empty(), and empty('0') is true
in PHP, so a note whose first line was exactly "0" was titled "New note".
Comparing against '' is what the guard meant.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
phpcs reports "The closing brace for the class must go on the next line
after the body" for the stray blank line between the last method and the
end of the class. It is the only phpcs error in the tree, so removing it
makes `make lint` pass.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger

Copy link
Copy Markdown
Member

@enjeck thanks for the review — both points should be covered now, and the branch is rebased on current main.

Fewer tests. The suite is down from 121 tests to 81, in 683 lines instead of 1390. The characterization tests that pinned known-bad behaviour in long docblocks are gone, along with a duplicate cursor round-trip, tests of trivial getters, and the thinner data-provider rows. It runs in about 50ms, so the unit-tests job time is almost entirely PHP setup and composer install.

Fewer comments. The explanatory blocks are gone throughout, including the workflow header you flagged inline. What is left in tests/unit/ is one inline comment (object storage returning false rather than '' for an empty file) plus two short docblocks describing what a helper builds.

Writing the tests did turn up three genuine bugs, so those are fixed in their own commits with regression tests rather than pinned: nested categories dropped by a + array union, excerpts mis-stripping multi-byte titles because a character count was handed to strncasecmp(), and a note whose first line is exactly 0 getting titled "New note". The first two I verified against a local instance before and after.

That does widen what started as a test-only PR, so happy to split the fix commits out if you would rather review them separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2. developing AI assisted This PR contains AI-assisted commits tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants